Catalisa.
Building blocks/ProdutividadeBeta

Calendar

Agenda do Google e do Outlook dos seus usuários, com disponibilidade calculada

25
Endpoints
5
Entidades
2
Provedores
Tenant
Escopo
3019
Porta

Seu produto passa a ler e escrever na agenda real do cliente — Google ou Outlook — e a responder "quais horários este consultor tem livres na semana que vem" sem que você escreva uma linha de código de fuso horário.

Para quem é
  • Fintechs e financeiras que agendam call de esteira, mesa de crédito ou assinatura de contrato
  • Operações de saúde e educação que marcam consulta ou aula na agenda de um profissional
  • Plataformas B2B cujo time comercial precisa expor horário livre para o cliente final escolher
  • Times de produto que já integraram uma agenda e não querem integrar a segunda
Substitui
  • Assinatura de uma API de calendário de terceiros por conta conectada (Nylas, Cronofy)
  • Integração direta e separada com Google Calendar API e Microsoft Graph, mantida em dobro
  • Cálculo caseiro de disponibilidade em cima de free/busy, com fuso e horário comercial na mão
O que não é
  • Uma página pública de agendamento pronta como a do Calendly (não há interface, só API)
  • Um calendário próprio — o dado mora na conta do usuário no Google ou na Microsoft
  • Um sistema de videoconferência ou de sala de reunião
  • Um motor de recorrência (RRULE) próprio — recorrência é delegada ao provedor

01Resumo executivo

O Calendar dá ao seu produto acesso à agenda real das pessoas. O usuário autoriza uma vez, pelo botão de sempre do Google ou da Microsoft, e a partir daí o seu sistema lê os compromissos dele, cria eventos com convidados e pergunta quais horários estão livres — sem que você precise conhecer duas APIs diferentes.

Na prática: uma financeira precisa marcar a call de análise com o analista certo. Em vez de manter uma agenda paralela que vive desatualizada porque o analista marcou um dentista no Outlook, o sistema pergunta ao Calendar "me dê cinco horários de 45 minutos na próxima semana, das 9h às 18h, só dias úteis, no fuso de São Paulo" e recebe cinco horários que de fato existem.

Está em beta. O código está em staging e produção desde fevereiro de 2026, com o caminho de leitura, escrita e cálculo de disponibilidade completo e coberto por testes. O que ainda não fecha é a notificação em tempo real do provedor: a plumbing existe inteira, mas não há endpoint para ligá-la e o job de renovação não é iniciado automaticamente (ver §15). Não anuncie sincronização push como recurso disponível.

AtributoValor
Identificadorcalendar
CategoriaProdutividade
EscopoTenant (exige organizationId no token)
Porta (standalone)3019
Path alias@calendar
Prefixo HTTP/calendar/api/v1/calendar — sim, calendar aparece duas vezes
StatusBeta desde 2026-02
Depende dePostgreSQL (schema calendar), IAM, Google Calendar API, Microsoft Graph

Atenção ao prefixo. O app Hono usa basePath('/calendar') e os routers são montados em /api/v1/calendar/.... A rota completa fica /calendar/api/v1/calendar/events, com o nome do módulo repetido. Não é erro de digitação desta documentação — é o que o código faz hoje. Toda rota da §9 está escrita na forma completa e real.


02O problemanegócio

O cenário. Seu produto precisa combinar um horário com alguém. Pode ser a call de crédito com o analista, a consulta com o médico, a visita técnica com o instalador, a reunião de renovação com o executivo de contas. O horário só vale se estiver na agenda que a pessoa de fato olha — e essa agenda é o Google Calendar ou o Outlook, não a sua tabela.

O que trava hoje.

  • São duas integrações, não uma. Google Calendar API e Microsoft Graph discordam em quase tudo: formato de evento, como se pede free/busy, como se assina notificação push, quanto tempo uma assinatura vive. Metade do seu mercado usa um, metade usa o outro. Você mantém as duas para sempre.
  • Fuso horário não perdoa. Um evento das 14h em São Paulo com um convidado em Lisboa, na semana em que um dos dois muda o horário de verão, é a classe de bug que só aparece em produção e sempre com o cliente errado. O Microsoft Graph, por exemplo, devolve horários de free/busy como relógio local sem offset se você não pedir explicitamente o contrário — quem lê como UTC bloqueia o horário errado.
  • Free/busy não é disponibilidade. O provedor devolve os blocos ocupados. Transformar isso em "cinco horários de 45 minutos, dentro do expediente, em dias úteis, espalhados pela semana" é código seu — e é código com aritmética de intervalo, mesclagem de sobreposição e conversão de fuso em cada passo.
  • OAuth por usuário final é um sistema, não uma tela. Você precisa guardar o refresh token de cada pessoa de cada cliente, cifrado, renovar antes de expirar, lidar com revogação silenciosa e não vazar o token de um tenant para outro.
  • O provedor muda por baixo. O usuário aceita um convite no celular, o chefe remarca a reunião, alguém cancela. Se o seu sistema só lê quando alguém pede, ele decide com dado velho.

O custo de não resolver. As duas contas mais fáceis de defender são as APIs em si. A Microsoft publica que uma assinatura de mudança em evento do Outlook expira em no máximo 10.080 minutos — pouco menos de sete dias — e precisa ser renovada antes disso (Microsoft Graph — Subscription lifetime, consulta em 2026-08-16). O Google é ainda mais direto: "Currently, there's no automatic way to renew a notification channel" (Google Calendar API — Push notifications, consulta em 2026-08-16). Ou seja: manter agenda sincronizada não é integrar uma API, é operar um serviço de renovação que roda para sempre, por conta conectada, com duas regras diferentes.


03Proposta de valornegócio

AntesDepois
Duas integrações — Google e Microsoft — com dois modelos de eventoUm tipo canônico de evento; o providerType é detalhe da conexão
Free/busy cru devolvido para o app do cliente resolverGET /slots devolve horários livres já filtrados por expediente, fuso e dia útil
Refresh token do usuário final guardado como derAES-256-GCM no banco, renovação automática com folga de 5 minutos antes de expirar
Cada tenant precisa do seu próprio app OAuth registrado no seu códigoCalendarProviderConfig por organização, com credenciais cifradas e teste de configuração
Aritmética de intervalo escrita de novo em cada produtoQuatro estratégias de seleção de horário prontas e trocáveis por parâmetro

Uma API para os dois provedores. CanonicalCalendarEvent, CanonicalBusyPeriod e CanonicalSlot são os mesmos tipos venha o dado do Google ou do Graph. O integrador escolhe a conexão; não escolhe o dialeto.

Disponibilidade calculada, não delegada. O SlotsService busca os períodos ocupados, mescla sobreposições, varre a janela em passos de 15 minutos respeitando periodStart, periodEnd e weekdaysOnly, e aplica a estratégia pedida. O que volta é horário que dá para oferecer ao usuário final.

OAuth multi-tenant de verdade. Cada organização registra o próprio app OAuth em CalendarProviderConfig. As credenciais entram cifradas e nunca voltam pela API — não existe endpoint que devolva client_secret.

Token renovado antes de quebrar. Toda operação passa por ensureFreshToken, que renova o access token quando faltam menos de 5 minutos para expirar. Conexão sem refresh token vira TOKEN_EXPIRED explicitamente, em vez de falhar com erro genérico do provedor.

Fuso tratado onde dói. As correções de fuso estão no provider, não no app do cliente: o Outlook recebe Prefer: outlook.timezone para não devolver relógio local sem offset, e consultas de dia único têm a janela expandida porque nenhum dos dois provedores aceita intervalo de comprimento zero.


04Casos de uso reaisnegócio

Caso 1 — Uma financeira oferece horário de analista sem manter agenda paralela Cenário ilustrativo

Contexto. Financeira de crédito para pequenas empresas, 40 analistas de mesa, cada proposta acima de um certo valor exige uma call de 45 minutos com o analista responsável.

A dor. A plataforma mantinha uma tabela de disponibilidade preenchida à mão pelos analistas. Ninguém preenchia. O resultado era o pior dos dois mundos: o cliente escolhia um horário que o analista não tinha, e o analista tinha horários livres que ninguém oferecia. A taxa de remarcação era o segundo maior motivo de atraso na esteira, atrás só de documentação pendente.

A solução com o BB. Cada analista conecta o Outlook corporativo uma vez, por POST /calendar/api/v1/calendar/connections/connect. Quando a proposta chega ao estágio de call, a plataforma chama GET /calendar/api/v1/calendar/slots com duration=45, periodStart=09:00, periodEnd=18:00, weekdaysOnly=true, algorithm=spread e timeZone=America/Sao_Paulo. O cliente vê cinco horários espalhados pela semana. Escolhido um, POST /calendar/api/v1/calendar/events cria o compromisso com o cliente como convidado.

O resultado. A agenda paralela deixa de existir. O horário oferecido é o horário que está livre no Outlook do analista naquele instante, e o convite chega ao cliente pelo próprio provedor.

Caso 2 — Uma operação de saúde unifica Google e Outlook sem escolher lado Cenário ilustrativo

Contexto. Rede de clínicas com 200 profissionais. Os médicos empregados usam o Microsoft 365 da rede; os credenciados usam Gmail pessoal ou da própria clínica.

A dor. A primeira versão do agendamento só falava com o Google. Os médicos empregados ficaram de fora, e a solução provisória foi um formulário que gerava e-mail para a secretária lançar manualmente. Metade da base numa integração, metade num processo humano.

A solução com o BB. A organização cria duas CalendarProviderConfig — uma GOOGLE e uma OUTLOOK — cada uma com o app OAuth correspondente. Cada profissional conecta pela config do provedor dele. Do lado do produto de agendamento, nada muda: o mesmo GET /slots e o mesmo POST /events, mudando apenas o connectionId.

O resultado. A regra de negócio do agendamento é escrita uma vez. Adicionar um provedor novo no futuro é implementar a interface CalendarProvider, não reescrever o produto.

Caso 3 — Um time comercial marca reuniões consecutivas em vez de picadas Cenário ilustrativo

Contexto. Operação de inside sales com executivos que fazem de seis a dez reuniões por dia.

A dor. O agendamento oferecia sempre o próximo horário livre. O resultado era uma agenda picada: reunião às 9h, às 11h30, às 14h, às 16h45. As janelas mortas entre elas eram curtas demais para trabalho concentrado e longas demais para ignorar.

A solução com o BB. Trocar um parâmetro: algorithm=consecutive. O ConsecutiveAlgorithm pontua janelas de horários candidatos — 100 pontos quando um horário começa exatamente onde o anterior termina, 50 quando o intervalo é de até 30 minutos, 25 quando é de até uma hora — e devolve o bloco de maior pontuação.

O resultado. As reuniões se agrupam. E, mais importante para quem compra: mudar a política de agendamento da operação é mudar uma string na chamada, não um sprint.

Caso 4 — O custo real de sincronizar agenda é a renovação, não a leitura Referência de mercado

Contexto. Quem projeta sincronização de agenda costuma orçar a leitura e esquecer a manutenção do canal de notificação. Os dois provedores são explícitos sobre isso na documentação pública.

A dor do mercado. A Microsoft publica que a assinatura de mudança em event do Outlook expira em no máximo 10.080 minutos, e cai para 1.440 minutos quando a notificação carrega dado do recurso (Microsoft Graph — Subscription lifetime, consulta em 2026-08-16). O Google afirma que "Currently, there's no automatic way to renew a notification channel. When a channel is close to its expiration, you must replace it with a new one by calling the watch method" (Google Calendar API — Push notifications, consulta em 2026-08-16). Com mil agendas conectadas, isso é um processo de renovação rodando continuamente, com duas regras distintas e nenhuma tolerância a falha silenciosa.

Como a Catalisa endereça. O modelo CalendarProviderSubscription guarda externalId, clientState e expiresAt de cada assinatura, o SubscriptionRenewalJob varre as que expiram nas próximas duas horas e as renova, e cada provider implementa renewSubscription no dialeto certo. Ressalva honesta: hoje não existe endpoint HTTP que crie a assinatura, e o job não é iniciado pelo main.ts. A peça está construída e testada, não está ligada. Ver §15.

O resultado. A arquitetura já assume que renovação é o custo dominante. O que falta é operacional, não conceitual — e está declarado como tal em vez de vendido como pronto.


05Mercado e diferenciaisnegócio

Panorama. Existem três caminhos para colocar agenda dentro de um produto. O primeiro é integrar direto com Google Calendar API e Microsoft Graph: sem custo de licença, controle total, e a obrigação de manter duas integrações incompatíveis para sempre. O segundo é contratar um agregador — Nylas, Cronofy — que unifica os provedores e cobra por conta conectada. O terceiro é embutir um produto de agendamento pronto, como Cal.com ou Calendly, aceitando o modelo de usuário e a interface deles.

O Calendar da Catalisa fica no segundo campo, com uma diferença de posicionamento: ele não é vendido por conta conectada, e o dado não sai do perímetro da plataforma que o cliente já contratou. Não é um agregador melhor que o Nylas — cobre dois provedores, o Nylas cobre muitos mais. É a peça de calendário de um catálogo que o cliente já usa para identidade, webhooks e o resto.

CritérioCatalisa CalendarNylasCronofyCal.comAPI direta
Provedores suportadosGoogle e OutlookGoogle, Microsoft, IMAP/CalDAV e outrosGoogle, Microsoft, Apple e outrosVários, via integraçõesUm por integração
Modelo de preçoEm definição — não é por conta conectadaPor conta conectada/mês (Calendar Only a partir de US$ 10/mês)Por usuário ativo, valor não públicoPor usuário/mês (US$ 12 a US$ 28 no anual)Sem licença hoje
Cálculo de disponibilidadeSim, com 4 estratégias, expediente e fusoSimSim, o mais completo do grupoSim, é o produtoVocê escreve
Interface de agendamento prontaNãoNãoNãoSimNão
OAuth por organização (app do próprio cliente)Sim, CalendarProviderConfig por tenantVia app do NylasSimDepende do planoVocê constrói
Onde o dado de agenda trafegaDentro da plataforma do clientePelo NylasPelo CronofyPelo Cal.com (ou auto-hospedado)Direto
Push do provedor em tempo realConstruído, não ligado (§15)SimSimSimVocê constrói
Recorrência (RRULE)Delegada ao provedor; criação com recorrência só no Outlook (§15)SimSimSimSim
Disponibilidade de várias pessoas combinadaNão (§15)SimSim, é o forte delesSimVocê escreve

Preços consultados nas páginas públicas dos fornecedores em 2026-08-16: Nylas, Cronofy, Cal.com, Google Calendar API — quotas. Variam por região, plano e negociação; confira na data da sua análise.

Nossos diferenciais

  1. O tipo canônico é o contrato, e ele é estreito de propósito. CanonicalCalendarEvent tem os campos que os dois provedores conseguem representar. É uma restrição, não um esquecimento: um tipo que fosse a união de Google e Graph obrigaria todo integrador a saber de qual provedor veio cada campo, o que anula o motivo de existir da camada.
  2. A disponibilidade sai pronta para oferecer, não crua para processar. Devolver free/busy é o fácil. O que o produto do cliente precisa é de horário — com expediente, dia útil, fuso e uma política de distribuição. Isso está dentro do building block, testado, e muda por parâmetro.
  3. O app OAuth é do cliente, não nosso. Cada organização registra o próprio client_id e client_secret. A tela de consentimento que o usuário final vê tem o nome do cliente, não o da Catalisa — o que importa em venda B2B e importa mais ainda na revisão de segurança do comprador.
  4. As correções de fuso estão no código, com o porquê escrito ao lado. O Prefer: outlook.timezone no getSchedule e a recusa em usar o literal me para free/busy do Outlook são dois bugs de duplo agendamento já pagos. Estão comentados no fonte porque a próxima pessoa não pode redescobri-los em produção.

Quando escolher o concorrente. Se você precisa de Apple Calendar, CalDAV ou IMAP, o Nylas e o Cronofy cobrem e o Calendar não. Se o seu problema é disponibilidade combinada de várias pessoas — "quando o vendedor, o engenheiro e o cliente estão livres juntos" — o Cronofy foi construído para isso e nós não temos. Se o que falta é uma página pública de agendamento com marca, pagamento e lembrete por SMS, o Cal.com e o Calendly entregam isso hoje e nós não entregamos interface nenhuma. E se você integra um provedor só, tem volume dentro das cotas gratuitas do Google e ninguém no time se incomoda de manter a integração, a API direta é mais barata que qualquer alternativa — inclusive esta. O Calendar ganha quando o problema é dois provedores, muitos tenants, e disponibilidade calculada dentro de uma plataforma que o cliente já usa.


06Modelo de cobrança e ROInegócio

Unidade de cobrança. Precificação em definição. O Calendar ainda não tem preço fechado, e não vamos inventar um aqui.

O que dispara custo. Três drivers, em ordem de peso:

DriverPor que pesa
Contas de calendário conectadasCada conexão é um par de tokens para renovar e, quando o push estiver ligado, uma assinatura para renovar continuamente no provedor
Chamadas de busca de horáriosGET /slots chama free/busy no provedor a cada requisição, sem cache; é a chamada mais cara do building block
Eventos criados ou alteradosEscrita no provedor, com convite disparado para os participantes

Comparação de custo — cenário: operação com 300 profissionais com agenda conectada, cerca de 2.000 buscas de horário e 800 eventos criados por mês.

Catalisa CalendarNylas (Calendar Only)API direta (Google + Graph)
Base de cálculoEm definiçãoUS$ 10/mês com 5 contas, depois US$ 1,50 por conta/mêsSem licença hoje
Ordem de grandeza mensal de licençaA definirOrdem de US$ 450/mês nesse volumeUS$ 0
Cálculo de disponibilidadeInclusoInclusoVocê escreve
Duas integrações para manterNãoNãoSim
Renovação de assinatura pushArquitetura pronta, não ligada (§15)InclusaVocê opera

Estimativa a partir da tabela pública do Nylas consultada em 2026-08-16: US$ 10 base + 295 contas adicionais × US$ 1,50. Não é proposta comercial e não considera desconto por volume, que o próprio Nylas oferece em plano Enterprise. Os valores da Catalisa não estão definidos.

ROI. A conta de guardanapo não é sobre licença — é sobre a integração que não é escrita. Manter duas integrações de calendário significa: dois modelos de evento mapeados, dois fluxos de OAuth, dois formatos de free/busy, duas semânticas de push com expirações diferentes, e a aritmética de fuso e disponibilidade em cima de tudo isso. Uma equipe experiente entrega a primeira versão em semanas; o custo verdadeiro é a manutenção, que não acaba, e os bugs de fuso e de duplo agendamento, que aparecem em produção e queimam confiança do usuário final. Dois desses bugs já estão pagos dentro deste building block e comentados no fonte.


07Arquitetura

                         HTTP
                           │
  ┌────────────────────────┴──────────────────────────────────────────────┐
  │ Hono app  basePath('/calendar')                                        │
  │                                                                        │
  │  /api/v1/calendar/provider-configs      providerConfigRouter      (6)  │
  │  /api/v1/calendar/connections           connectionRouter          (4)  │
  │  /api/v1/calendar/events                eventsRouter              (7)  │
  │  /api/v1/calendar/calendars             calendarsRouter           (1)  │
  │  /api/v1/calendar/slots                 slotsRouter               (1)  │
  │  /api/v1/calendar/webhooks              webhookRouter             (4)  │
  │  /api/v1/calendar/providers/notifications  providerNotification…  (2)  │
  │  /health                                                               │
  └────────────────────────┬──────────────────────────────────────────────┘
                           │  Zod parse → ResultAsync<T, AppError>
  ┌────────────────────────┴──────────────────────────────────────────────┐
  │ services/                                                              │
  │   CalendarProviderConfigService   credenciais OAuth cifradas por org    │
  │   CalendarConnectionService       OAuth, tokens, ensureFreshToken       │
  │   CalendarService                 CRUD de evento (delega ao provider)   │
  │   SlotsService                    free/busy → horários livres           │
  │   CalendarWebhookService          entrega de evento ao cliente (HMAC)   │
  │   CalendarProviderSubscriptionService  push do provedor  ← ver §15      │
  └────────────────────────┬──────────────────────────────────────────────┘
                           │
        ┌──────────────────┴───────────────────┐
        ▼                                       ▼
  ┌───────────────────────┐          ┌──────────────────────────────┐
  │ repositories/ (Prisma)│          │ providers/  interface única   │
  │  schema "calendar"    │          │   GoogleProvider              │
  │  5 modelos            │          │   OutlookProvider             │
  └───────────────────────┘          └───────────┬──────────────────┘
                                                  │
                                  ┌───────────────┴────────────────┐
                                  ▼                                ▼
                    Google Calendar API              Microsoft Graph v1.0

Caminho de uma busca de horários

 GET /calendar/api/v1/calendar/slots?connectionId=…&duration=45&timeZone=America/Sao_Paulo
        │
        ▼
 authMiddleware → requirePermission(CALENDAR_READ) → requireOrganization
        │
        ▼
 SlotsService.findSlots
        │
        ├─▶ ConnectionService.getProviderForConnection(connectionId, organizationId)
        │        │  1. busca a conexão JÁ FILTRADA por organizationId
        │        │  2. recusa se status = REVOKED
        │        │  3. ensureFreshToken — renova se faltam < 5 min para expirar
        │        │  4. decifra tokens (AES-256-GCM) e monta o provider
        │        ▼
        ├─▶ provider.getBusyPeriods()   Google: POST /freeBusy
        │                               Outlook: POST /me/calendar/getSchedule
        │                                        + header Prefer: outlook.timezone
        │        ▼
        ├─▶ mergeBusyPeriods()      mescla blocos ocupados sobrepostos
        ├─▶ generateSlots()         varre dia a dia, passo de 15 min,
        │                           aplica periodStart/periodEnd/weekdaysOnly
        └─▶ algorithm.select()      nextAvailable | randomize | spread | consecutive
                 │
                 ▼
           CanonicalSlot[]

Decisões não óbvias.

  • Free/busy é consultado ao vivo, sem cache. Um horário livre em cache é um duplo agendamento esperando acontecer. O custo é latência — uma chamada ao provedor por busca — e o benefício é que o horário oferecido é o horário que existe. Só faz sentido cachear quando o push do provedor estiver ligado e puder invalidar (§15).
  • O passo da varredura é fixo em 15 minutos. Vem de stepMs no SlotsService. Quinze minutos é o menor incremento que gente usa para marcar reunião; um passo menor multiplicaria os candidatos sem gerar horário que alguém escolheria.
  • accountEmail é obrigatório para free/busy do Outlook. O getSchedule do Graph exige endereço SMTP em schedules. O literal me não é resolvido e devolve agenda vazia em silêncio — o que faz todo horário parecer livre e causa duplo agendamento. O provider falha alto quando o accountEmail está ausente, em vez de devolver agenda vazia.
  • Prefer: outlook.timezone no getSchedule. Sem esse header o Graph devolve os horários dos blocos ocupados como relógio local sem offset. Quem lê como UTC desloca cada bloco pela diferença local↔UTC e bloqueia os horários errados. O header fixa o fuso da resposta e o mapper converte para instante UTC.
  • Janela de dia único é expandida antes de ir ao provedor. Nenhum dos dois aceita intervalo de comprimento zero, e uma busca de um dia chega com startDate === endDate. O Google normaliza pelos helpers de fuso; o Outlook expande para T00:00:00T23:59:59.
  • Os tokens de acesso e de refresh são gravados no mesmo envelope cifrado. encryptCredentials({ accessToken, refreshToken }) gera uma string, e essa string vai tanto para a coluna accessToken quanto para refreshToken. Guardar um envelope só evita dois estados de cifra fora de sincronia — o preço é que a coluna refreshToken é redundante hoje.
  • O state do OAuth é um envelope cifrado, não um identificador de sessão. organizationId, userId, configId e redirectUrl viajam cifrados com AES-256-GCM dentro do próprio state. O callback é público — não tem token da plataforma — e mesmo assim sabe a que tenant pertence, sem consultar tabela de sessão. Como o GCM é autenticado, um state adulterado falha a decifragem e a requisição morre em Invalid callback state.
  • GET /events/:id, DELETE /events/:id e as rotas de convidado exigem connectionId na query. O identificador do evento pertence ao provedor, não a nós; sem saber por qual conexão perguntar, o building block não tem como resolvê-lo. A falta do parâmetro retorna 400 antes de qualquer chamada externa.

Monolito vs. standalone. Em monolito, o Calendar é resolvido pelo container TypeDI junto com os demais. Em standalone — o modo de produção — sobe como serviço próprio (porta 3019 no compose local) e valida o JWT localmente com o JWT_SECRET compartilhado, sem chamada de rede ao IAM por requisição.


08Conceitos e modelo de dados

Glossário

TermoSignifica
Provider configO app OAuth de uma organização junto a um provedor. Guarda client_id, client_secret e redirect_uri cifrados. Uma organização pode ter várias; uma é a padrão.
ConnectionO vínculo entre um usuário da plataforma e uma conta de calendário externa, criado quando o usuário conclui o consentimento. Carrega os tokens cifrados.
Provider subscriptionA assinatura de notificação push registrada no provedor (channel do Google, subscription do Graph). Expira e precisa ser renovada.
Webhook subscriptionO caminho inverso: para onde nós entregamos o evento, no sistema do cliente. Assinada com HMAC-SHA256.
Canonical eventRepresentação de evento independente de provedor. É o que a API devolve, sempre.
Busy periodBloco de tempo ocupado devolvido pelo provedor. Insumo do cálculo, não resposta ao cliente.
SlotHorário livre calculado — início e fim, no fuso pedido. É o que se oferece ao usuário final.
AlgorithmEstratégia de escolha entre os horários livres candidatos: nextAvailable, randomize, spread, consecutive.
Client stateSegredo por assinatura que o provedor devolve em cada notificação. É como se confirma que a notificação é daquela assinatura.

Modelo de dados — schema calendar no PostgreSQL.

Modelo PrismaTabelaPropósitoCampos-chave
CalendarProviderConfigcalendar.calendar_provider_configsApp OAuth da organizaçãocredentials (cifrado), providerType, isDefault, isActive, único (organizationId, name)
CalendarConnectioncalendar.calendar_connectionsConta de calendário conectada por um usuárioexternalEmail, accessToken e refreshToken (cifrados), tokenExpiresAt, status
CalendarProviderSubscriptioncalendar.calendar_provider_subscriptionsAssinatura push registrada no provedorexternalId (único), resource, clientState, expiresAt, isActive
CalendarWebhookSubscriptioncalendar.calendar_webhook_subscriptionsPara onde entregamos evento no clientecallbackUrl, events[], secret, isActive
CalendarWebhookDeliveryLogcalendar.calendar_webhook_delivery_logsHistórico de cada tentativa de entregastatus, statusCode, retryCount, nextRetryAt, payload

Enumerações

EnumValores
CalendarProviderTypeGOOGLE · OUTLOOK
CalendarConnectionStatusACTIVE · TOKEN_EXPIRED · REVOKED · ERROR
CalendarWebhookEventTypeEVENT_CREATED · EVENT_UPDATED · EVENT_DELETED · EVENT_CANCELLED
CalendarDeliveryStatusPENDING · SUCCESS · FAILED · RETRYING
SlotAlgorithmnextAvailable · randomize · spread · consecutive

Ciclo de vida da conexão

   POST /connections/connect
          │  gera state cifrado (org + user + config + redirectUrl)
          ▼
   usuário consente no Google / Microsoft
          │
          ▼
   GET /connections/callback   (rota pública, sem token da plataforma)
          │  decifra o state → troca code por tokens → cifra e grava
          ▼
     ┌──────────┐
     │  ACTIVE  │◀──────── ensureFreshToken renova com 5 min de folga
     └────┬─────┘                        │
          │                              │ renovação bem-sucedida
          │ sem refresh token            └──────────────┐
          ▼                                             │
   ┌───────────────┐   nova autorização do usuário      │
   │ TOKEN_EXPIRED │ ───────────────────────────────────┘
   └───────────────┘

   DELETE /connections/:id  →  exclusão lógica (deletedAt)
   REVOKED  →  toda operação é recusada com ValidationError
   ERROR    →  previsto no enum, não atribuído por nenhum caminho hoje (§15)

Como um horário livre nasce

  Blocos ocupados do provedor        Restrições da requisição
  (free/busy, podem se sobrepor)     duration, periodStart/End,
            │                        weekdaysOnly, timeZone
            ▼                                 │
     mergeBusyPeriods()                       │
     mescla sobreposições                     │
            └────────────┬────────────────────┘
                         ▼
                 generateSlots()
      dia a dia, passo de 15 min, descarta o que colide
                         │
                         ▼
             candidatos livres (ordenados)
                         │
                         ▼
                algorithm.select(quantity)
                         │
                         ▼
                  CanonicalSlot[]

09Referência da API

Prefixo real: /calendar/api/v1/calendar. O nome do módulo aparece duas vezes porque o app usa basePath('/calendar') e os routers são montados sob /api/v1/calendar. Em standalone, a base é https://calendar.stg.catalisa.app.

Salvo indicação de rota pública, toda rota exige authMiddleware, o requirePermission(...) da tabela e requireOrganization — um token sem organizationId recebe 403 com Organization context required.

Configurações de provedor — /calendar/api/v1/calendar/provider-configs

MétodoRotaDescriçãoPermissão
POST/calendar/api/v1/calendar/provider-configsCria a config OAuth da organizaçãoCALENDAR_ADMIN
GET/calendar/api/v1/calendar/provider-configsLista as configs da organizaçãoCALENDAR_READ
GET/calendar/api/v1/calendar/provider-configs/:idBusca uma configCALENDAR_READ
PATCH/calendar/api/v1/calendar/provider-configs/:idAtualiza a configCALENDAR_ADMIN
DELETE/calendar/api/v1/calendar/provider-configs/:idExclusão lógica. 204CALENDAR_ADMIN
POST/calendar/api/v1/calendar/provider-configs/:id/testValida se as credenciais montam um providerCALENDAR_ADMIN

Conexões — /calendar/api/v1/calendar/connections

MétodoRotaDescriçãoPermissão
POST/calendar/api/v1/calendar/connections/connectDevolve a URL de consentimento do provedorCALENDAR_WRITE
GET/calendar/api/v1/calendar/connections/callbackCallback OAuth. Rota pública — sem authMiddlewarePública
GET/calendar/api/v1/calendar/connectionsLista as conexões do usuário do tokenCALENDAR_READ
DELETE/calendar/api/v1/calendar/connections/:idDesconecta. Exclusão lógica. 204CALENDAR_WRITE

Calendários e horários

MétodoRotaDescriçãoPermissão
GET/calendar/api/v1/calendar/calendars?connectionId=Lista os calendários da conta conectadaCALENDAR_READ
GET/calendar/api/v1/calendar/slotsCalcula horários livresCALENDAR_READ

Eventos — /calendar/api/v1/calendar/events

MétodoRotaDescriçãoPermissão
GET/calendar/api/v1/calendar/eventsLista eventos numa janelaCALENDAR_READ
GET/calendar/api/v1/calendar/events/:id?connectionId=Busca um eventoCALENDAR_READ
POST/calendar/api/v1/calendar/eventsCria evento. 201CALENDAR_WRITE
PATCH/calendar/api/v1/calendar/events/:idAtualiza eventoCALENDAR_WRITE
DELETE/calendar/api/v1/calendar/events/:id?connectionId=Remove evento. 204CALENDAR_WRITE
POST/calendar/api/v1/calendar/events/:id/attendeesAdiciona convidadosCALENDAR_WRITE
DELETE/calendar/api/v1/calendar/events/:id/attendees/:email?connectionId=Remove um convidadoCALENDAR_WRITE

Webhooks de saída — /calendar/api/v1/calendar/webhooks

MétodoRotaDescriçãoPermissão
POST/calendar/api/v1/calendar/webhooksCria assinatura de entrega. 201CALENDAR_ADMIN
GET/calendar/api/v1/calendar/webhooksLista assinaturasCALENDAR_READ
PATCH/calendar/api/v1/calendar/webhooks/:idAtualiza assinaturaCALENDAR_ADMIN
DELETE/calendar/api/v1/calendar/webhooks/:idRemove assinatura. 204CALENDAR_ADMIN

Notificações do provedor — rotas públicas

MétodoRotaDescriçãoPermissão
POST/calendar/api/v1/calendar/providers/notifications/outlookCallback do Microsoft Graph. Devolve validationToken em texto puro quando presente; senão 202Pública
POST/calendar/api/v1/calendar/providers/notifications/googleCallback do Google (X-Goog-Channel-ID). 200Pública

Saúde

MétodoRotaDescrição
GET/calendar/healthSonda de vida e versão do build

GET /calendar/api/v1/calendar/slots

O endpoint mais importante do building block. Todos os parâmetros vão na query string.

CampoTipoObrigatórioDescrição
connectionIdstring (UUID)SimConexão a consultar
calendarIdstringNãoCalendário específico. Omitido, usa o primário da conta
startDatestringSimInício da janela. Aceita 2026-09-01 ou ISO com hora
endDatestringSimFim da janela. Data pura vira 23:59:59.999 no fuso pedido
durationnumberSimDuração do horário em minutos. 5 a 480
timeZonestringSimIANA, ex.: America/Sao_Paulo. Define o expediente e o formato de saída
quantitynumberNãoQuantos horários devolver. 1 a 100, padrão 10
algorithmenumNãonextAvailable (padrão) · randomize · spread · consecutive
periodStartstringNãoInício do expediente, HH:mm. Omitido, o dia começa à meia-noite
periodEndstringNãoFim do expediente, HH:mm. Omitido, o dia vai até a meia-noite seguinte
weekdaysOnlybooleanNãotrue descarta sábado e domingo. Padrão false

As quatro estratégias

ValorO que fazQuando usar
nextAvailableOs quantity primeiros horários, do mais próximo ao mais distantePadrão. "O quanto antes"
randomizeAmostra aleatória do conjunto livre, devolvida em ordem cronológicaDistribuir demanda entre horários sem viés pela manhã
spreadDistribui pelos dias, um por dia enquanto derDar opção em dias diferentes, não três horários da mesma terça
consecutiveBusca o bloco de horários mais grudados entre siEvitar agenda picada; reuniões em sequência

Resposta 200

{
  "data": [
    { "start": "2026-09-01T09:00:00.000", "end": "2026-09-01T09:45:00.000" },
    { "start": "2026-09-02T14:30:00.000", "end": "2026-09-02T15:15:00.000" },
    { "start": "2026-09-03T10:15:00.000", "end": "2026-09-03T11:00:00.000" }
  ]
}

Os horários voltam no fuso pedido, sem sufixo Z. 2026-09-01T09:00:00.000 com timeZone=America/Sao_Paulo significa nove da manhã em São Paulo. Não trate como UTC.

Erros

StatusQuando
400Query reprovada no Zod — duration fora de 5–480, periodStart fora de HH:mm, connectionId não é UUID
403Token sem organizationId, ou sem CALENDAR_READ
404connectionId não existe na organização do token
500Conexão REVOKED, falha ao renovar o token, ou o provedor recusou o free/busy

POST /calendar/api/v1/calendar/connections/connect

Primeiro passo do OAuth. Não conecta nada — devolve a URL para onde você manda o usuário.

Request

{
  "configId": "8f14e45f-ceea-467a-9e1a-2b3c4d5e6f70",
  "redirectUrl": "https://app.suaempresa.com.br/agenda/conectado",
  "scopes": ["https://www.googleapis.com/auth/calendar.events"]
}
CampoTipoObrigatórioDescrição
configIdstring (UUID)SimQual CalendarProviderConfig usar
redirectUrlstring (URL)SimPara onde o usuário volta depois do callback. Viaja cifrado no state
scopesstring[]NãoSobrescreve os escopos padrão. Use com cuidado — escopo a menos quebra funcionalidade em tempo de execução, não na conexão

Escopos padrão por provedor

ProvedorEscopos
GOOGLEcalendar.events · calendar.freebusy · calendar.calendarlist.readonly · calendar.calendars.readonly · userinfo.email · openid
OUTLOOKUser.Read · Calendars.ReadWrite · offline_access

Os escopos do Google são granulares de propósito, e não o escopo calendar completo: assim a verificação OAuth do Google não exige avaliação CASA de escopo restrito. Trocar por calendar inteiro muda o processo de aprovação do app do seu cliente.

Resposta 200

{ "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&state=..." }

Erros

StatusQuando
400Corpo reprovado no Zod, ou providerType da config não é GOOGLE nem OUTLOOK
403Sem CALENDAR_WRITE ou sem organização no token
404configId inexistente na organização

POST /calendar/api/v1/calendar/events

Request

{
  "connectionId": "8f14e45f-ceea-467a-9e1a-2b3c4d5e6f70",
  "subject": "Análise de crédito — Padaria do Bairro",
  "body": "Pauta: faturamento dos últimos 12 meses e garantias.",
  "bodyContentType": "text",
  "location": "Videoconferência",
  "start": "2026-09-01T09:00:00",
  "end": "2026-09-01T09:45:00",
  "timeZone": "America/Sao_Paulo",
  "isAllDay": false,
  "isOnlineMeeting": true,
  "attendees": [
    { "email": "cliente@padaria.com.br", "name": "Maria Souza", "type": "required" }
  ]
}
CampoTipoObrigatórioPadrão
connectionIdstring (UUID)Sim
calendarIdstringNãoCalendário primário da conta
subjectstring (mín. 1)Sim
bodystringNão
bodyContentTypetext | htmlNãotext
locationstringNão
start / endstringSim
timeZonestring (IANA)Sim
isAllDaybooleanNãofalse
isOnlineMeetingbooleanNãofalse
attendees[]{ email, name?, type? }Nãotype = required
recurrenceobjectNãoAceito pelo schema. Só o provider Outlook o repassa — ver §15

Resposta 201 — um CanonicalCalendarEvent em data, com id do provedor, webLink e a lista de convidados com o status de cada um.

Erros

StatusQuando
400Corpo reprovado no Zod
403Sem CALENDAR_WRITE ou sem organização
404connectionId inexistente na organização
500Token não renovável, tokens indecifráveis, ou o provedor recusou a criação

POST /calendar/api/v1/calendar/webhooks

Registra para onde o building block entrega eventos de calendário no seu sistema.

Request

{
  "callbackUrl": "https://api.suaempresa.com.br/hooks/calendario",
  "events": ["EVENT_CREATED", "EVENT_UPDATED", "EVENT_CANCELLED"],
  "secret": "um-segredo-de-no-minimo-16-caracteres",
  "description": "Ingestão de agenda no CRM"
}

secret é opcional; omitido, o serviço gera 32 bytes aleatórios em hex. Ele não é devolvido na resposta — guarde o seu se precisar validar assinatura.

Como validar a entrega. Cada POST leva:

CabeçalhoConteúdo
X-Webhook-Signaturesha256=<HMAC-SHA256 do corpo bruto, com o seu secret>
X-Webhook-EventO CalendarWebhookEventType
X-Webhook-IdIdentificador da entrega, o mesmo do log

O corpo é {"eventType": "...", "data": {...}, "timestamp": "..."}. Calcule o HMAC sobre o corpo bruto, antes de qualquer parse — reserializar muda os bytes e quebra a comparação. Tempo limite de 30 segundos por entrega.


10Início rápido

Do zero ao primeiro horário livre calculado. Os comandos abaixo não foram executados ao escrever esta documentação — conferem com o código e os schemas Zod, mas trate-os como referência e não como saída capturada.

1. Autenticar no IAM

TOKEN=$(curl -s -X POST https://iam.stg.catalisa.app/iam/api/v1/users/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@catalisa.app","password":"password123",
       "organizationId":"b0000000-0000-0000-0000-000000000001"}' | jq -r .accessToken)

BASE=https://calendar.stg.catalisa.app/calendar/api/v1/calendar

2. Registrar o app OAuth da organização

CONFIG=$(curl -s -X POST "$BASE/provider-configs" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "name": "google-calendar-producao",
    "providerType": "GOOGLE",
    "credentials": {
      "client_id": "SEU_CLIENT_ID.apps.googleusercontent.com",
      "client_secret": "SEU_CLIENT_SECRET",
      "redirect_uri": "https://calendar.stg.catalisa.app/calendar/api/v1/calendar/connections/callback"
    },
    "isDefault": true
  }')
CONFIG_ID=$(echo "$CONFIG" | jq -r '.data.id')

O redirect_uri precisa ser exatamente o que você cadastrou no console do Google. Divergência de uma barra devolve redirect_uri_mismatch na tela de consentimento.

3. Conferir que a config é válida

curl -s -X POST "$BASE/provider-configs/$CONFIG_ID/test" \
  -H "Authorization: Bearer $TOKEN" | jq
{ "success": true, "message": "Provider configuration is valid" }

Esse teste valida forma, não credencial. Ele confirma que dá para montar um provider com o que foi guardado; não conversa com o Google. A validação real acontece no consentimento.

4. Obter a URL de consentimento e conectar

curl -s -X POST "$BASE/connections/connect" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"configId\":\"$CONFIG_ID\",\"redirectUrl\":\"https://app.exemplo.com.br/pronto\"}" \
  | jq -r .authorizationUrl

Abra a URL no navegador, autorize, e o callback grava a conexão e redireciona para o redirectUrl.

5. Listar as conexões do usuário

curl -s "$BASE/connections" -H "Authorization: Bearer $TOKEN" | jq '.data[] | {id, attributes}'
CONN_ID=$(curl -s "$BASE/connections" -H "Authorization: Bearer $TOKEN" | jq -r '.data[0].id')

6. Perguntar quais horários estão livres

curl -s -G "$BASE/slots" -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "connectionId=$CONN_ID" \
  --data-urlencode "startDate=2026-09-01" \
  --data-urlencode "endDate=2026-09-05" \
  --data-urlencode "duration=45" \
  --data-urlencode "timeZone=America/Sao_Paulo" \
  --data-urlencode "periodStart=09:00" \
  --data-urlencode "periodEnd=18:00" \
  --data-urlencode "weekdaysOnly=true" \
  --data-urlencode "algorithm=spread" \
  --data-urlencode "quantity=5" | jq
{
  "data": [
    { "start": "2026-09-01T09:00:00.000", "end": "2026-09-01T09:45:00.000" },
    { "start": "2026-09-02T09:00:00.000", "end": "2026-09-02T09:45:00.000" },
    { "start": "2026-09-03T09:00:00.000", "end": "2026-09-03T09:45:00.000" }
  ]
}

7. Marcar um dos horários

curl -s -X POST "$BASE/events" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"connectionId\": \"$CONN_ID\",
    \"subject\": \"Análise de crédito\",
    \"start\": \"2026-09-01T09:00:00\",
    \"end\": \"2026-09-01T09:45:00\",
    \"timeZone\": \"America/Sao_Paulo\",
    \"attendees\": [{\"email\": \"cliente@exemplo.com.br\", \"type\": \"required\"}]
  }" | jq '.data | {id, subject, start, end, webLink}'

O evento aparece no Google Calendar da pessoa e o convite chega ao cliente.

Credenciais de staging. Nunca cole segredo de produção ou client_secret de cliente em documentação ou script — ver AMBIENTES.md.


11Receitas

Reaproveitar o mesmo app OAuth para Email e Calendar

Google e Microsoft não exigem um app por API — o mesmo client_id serve para agenda e caixa de entrada, mudando escopos e redirect_uri. Existe um script pronto para copiar as credenciais já cadastradas no building block Email:

CALENDAR_CALLBACK_BASE=https://calendar.bb.stg.catalisa.app/calendar \
EMAIL_CREDENTIAL_MASTER_KEY=<chave-do-email> \
CALENDAR_CREDENTIAL_MASTER_KEY=<chave-do-calendar> \
bun run scripts/create-calendar-configs-from-email.ts --dry-run

O script decifra a credencial do Email com a chave do Email, monta {client_id, client_secret, redirect_uri} apontando para o callback do Calendar, e cifra de novo com a chave do Calendar. É idempotente (upsert por organização + nome) e reversível. Rode sempre com --dry-run primeiro.

Armadilhas.

  • Cada API precisa estar habilitada no projeto do Google. App que só tinha a Gmail API ligada falha na primeira chamada de calendário, não na conexão.
  • O redirect_uri do Calendar precisa ser cadastrado no console do provedor. O script monta a string certa, mas não cadastra por você.
  • As duas chaves mestras são diferentes por design. Reusar uma chave para os dois módulos transforma o vazamento de um em vazamento dos dois.

Oferecer horários e marcar sem risco de duplo agendamento

# 1. Buscar horários — sempre ao vivo, sem cache do seu lado
SLOTS=$(curl -s -G "$BASE/slots" -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "connectionId=$CONN_ID" \
  --data-urlencode "startDate=2026-09-01" --data-urlencode "endDate=2026-09-05" \
  --data-urlencode "duration=30" --data-urlencode "timeZone=America/Sao_Paulo" \
  --data-urlencode "periodStart=09:00" --data-urlencode "periodEnd=18:00")

# 2. O usuário escolhe. Rebusque antes de gravar se passou tempo.
CHOSEN_START=$(echo "$SLOTS" | jq -r '.data[0].start')
CHOSEN_END=$(echo "$SLOTS" | jq -r '.data[0].end')

# 3. Criar
curl -s -X POST "$BASE/events" -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"connectionId\":\"$CONN_ID\",\"subject\":\"Reunião\",
       \"start\":\"$CHOSEN_START\",\"end\":\"$CHOSEN_END\",
       \"timeZone\":\"America/Sao_Paulo\"}"

Armadilhas.

  • Não guarde a lista de horários por muito tempo. Ela é uma foto. Se o usuário demorar cinco minutos escolhendo, rebusque antes de gravar — o provedor não trava horário para você.
  • Não há reserva otimista. Se dois usuários seus escolherem o mesmo horário na mesma janela, os dois eventos são criados. Se isso importa, mantenha um lock do seu lado, na sua tabela, antes de chamar POST /events.
  • Os horários vêm sem Z. Mande de volta em start/end exatamente como vieram, com o mesmo timeZone, e não converta no meio do caminho.

Diagnosticar por que uma busca de horários voltou vazia

Em ordem, do mais comum ao mais raro:

# A conexão está viva?
curl -s "$BASE/connections" -H "Authorization: Bearer $TOKEN" \
  | jq '.data[] | {id, status: .attributes.status, email: .attributes.externalEmail}'
SintomaCausa provávelO que fazer
data: [] e a agenda tem buracos evidentesJanela periodStartperiodEnd menor que a duration pedidaAumente a janela ou reduza a duração
data: [] e o intervalo é de um dia sóstartDate === endDate sem hora — já é tratado, mas confirme que a data é a certa no fuso pedidoConfira o fuso; 2026-09-01 em America/Sao_Paulo não é o mesmo dia em UTC
data: [] com weekdaysOnly=trueA janela caiu inteira em fim de semana ou feriado prolongadoO building block não conhece feriado — ver §15
Horários que estão ocupados aparecem como livres, no OutlookConexão sem externalEmailReconecte a conta; o free/busy do Graph exige o endereço SMTP e o provider recusa sem ele
500 com falha de free/busyEscopo insuficiente na conexãoReconecte com os escopos padrão. calendar.freebusy não vem junto com calendar.events no Google

Reagir a mudanças de agenda hoje, sem push

O push do provedor está construído mas não ligado (§15). Enquanto isso:

# Polling da janela relevante — leia só o que importa para você
curl -s -G "$BASE/events" -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "connectionId=$CONN_ID" \
  --data-urlencode "startDateTime=2026-09-01T00:00:00" \
  --data-urlencode "endDateTime=2026-09-08T00:00:00" \
  --data-urlencode "timeZone=America/Sao_Paulo" \
  --data-urlencode "pageSize=100" | jq '.data[] | {id, subject, start, isCancelled}'

Armadilhas.

  • Não use GET /slots como detector de mudança. Ele é a chamada mais cara do módulo. Use GET /events na janela que interessa.
  • pageSize é limitado a 100. Use meta.nextPageToken para paginar.
  • No Google, a listagem já vem com recorrências expandidas em ocorrências individuais (singleEvents: true). Não espere um evento-mãe com regra de repetição.
  • Registrar um webhook em POST /webhooks não faz eventos chegarem hoje: a origem que os dispararia é a notificação do provedor, que não está ligada. A assinatura fica gravada e válida para quando estiver.

12Integração com outros building blocks

Building blockComo se relacionaObrigatório
IAMEmite o token com organizationId e as permissões CALENDAR_*Sim
EmailMesmo padrão de integração OAuth — ver abaixo. Compartilha o app OAuth do provedorNão
Webhooks EngineEntrega de eventos com assinatura, retry e log. O Calendar hoje entrega por conta própria (§15)Não
SSOFederação de identidade. Também guarda credenciais de provedor cifradas com chave mestra própriaNão
Audit TrailRegistro de quem conectou, desconectou ou alterou config de provedorNão

A simetria com o Email — e por que ela importa

O Calendar e o Email são o mesmo desenho aplicado a duas superfícies do mesmo provedor. Os dois têm connection, provider-config, provider-notification e webhook; os dois cifram credenciais com AES-256-GCM sob uma chave mestra própria; os dois usam Google e Microsoft como provedores.

       ┌──────────────────────────────────────────────────────────┐
       │  Um único app OAuth registrado pelo cliente               │
       │  no Google Cloud Console / Microsoft Entra                │
       │  client_id · client_secret                                │
       └───────────────┬───────────────────────┬──────────────────┘
                       │                       │
        redirect_uri do Calendar   redirect_uri do Email
        escopos de calendário      escopos de caixa de entrada
                       │                       │
                       ▼                       ▼
      ┌────────────────────────┐   ┌────────────────────────┐
      │  BB Calendar            │   │  BB Email              │
      │  CalendarProviderConfig │   │  EmailProviderConfig   │
      │  cifrado com            │   │  cifrado com           │
      │  CALENDAR_CREDENTIAL_…  │   │  EMAIL_CREDENTIAL_…    │
      └───────────┬─────────────┘   └───────────┬────────────┘
                  │                              │
                  └──────────────┬───────────────┘
                                 ▼
                    O usuário final consente UMA vez
                    por superfície, com a marca do cliente

O ganho comercial é direto: o cliente registra um app OAuth, não dois, e passa por uma verificação do Google, não duas. O script scripts/create-calendar-configs-from-email.ts existe exatamente para materializar isso (receita em §11). As chaves mestras seguem separadas de propósito — comprometer a do Email não entrega a agenda, e vice-versa.

Onde o Calendar entra numa cadeia

  ┌──────────┐    proposta chega ao estágio de call
  │ Decision │──────────────────────┐
  │ Platform │                      │
  └──────────┘                      ▼
                          ┌───────────────────┐
                          │     Calendar      │
                          │  GET /slots       │──▶ horários livres reais
                          │  POST /events     │──▶ evento + convite
                          └─────────┬─────────┘
                                    │  calendar.event.created
                                    ▼
                        ┌───────────────────────┐
                        │   Webhooks Engine     │──▶ CRM do cliente
                        └───────────────────────┘
                                    │
                                    ▼
                        ┌───────────────────────┐
                        │     Audit Trail       │
                        └───────────────────────┘

13Configuração e operação

Variáveis de ambiente

VariávelDescriçãoObrigatóriaPadrão
CALENDAR_CREDENTIAL_MASTER_KEYChave AES-256-GCM que cifra credenciais e tokens. 64 caracteres hex (32 bytes). Gere com openssl rand -hex 32Sim, para usar o módulo
DATABASE_URLPostgreSQL, schema calendarSim
JWT_SECRETCompartilhado com o IAM, mínimo 44 caracteresSim
MODULE_CALENDAR_URLURL interna do serviço em standaloneEm standalone''
MODULE_IAM_URLURL interna do IAMEm standalone
DEPLOYMENT_MODEmonolith ou standaloneNãomonolith
PORTPorta do processo. No compose local, mapeada para 3019Não3000

A chave mestra é validada no formato só quando usadagetMasterKey() lê direto de process.env e exige 64 caracteres hex. Uma chave malformada não derruba o boot; derruba a primeira operação que precise cifrar ou decifrar. Verifique com POST /provider-configs logo após o deploy.

Dependências de infraestrutura

DependênciaPara quê
PostgreSQLSchema calendar, 5 tabelas
IAMEmissão e validação do token
Google Calendar APIEventos, free/busy, watch
Microsoft Graph v1.0Eventos, getSchedule, subscriptions

Limites e quotas

LimiteValorOrigem
Duração do horário em /slots5 a 480 minutosSchema Zod
Quantidade de horários por busca1 a 100, padrão 10Schema Zod
Passo da varredura de horários15 minutos, fixoSlotsService
pageSize na listagem de eventos1 a 100, padrão 50Schema Zod
Tempo limite de entrega de webhook30 segundosCalendarWebhookService
secret do webhookMínimo 16 caracteresSchema Zod
Expiração pedida na assinatura do provedor4.230 minutos (~2,9 dias)Constante no código
Google Calendar API10.000 req/min por projeto, 600 req/min por usuário, 1.000.000/diaDocumentação do Google, 2026-08-16
Assinatura de event no GraphMáximo 10.080 minutos; 1.000 assinaturas ativas por caixaDocumentação da Microsoft, 2026-08-16

O código pede 4.230 minutos de expiração, valor bem abaixo do teto de 10.080 do Graph para eventos do Outlook. É conservador e funciona, mas significa renovar com mais frequência do que o provedor exige.

Catálogo de erros

StatusCódigoSignificaO que fazer
400VALIDATIONCorpo ou query reprovados no ZodConfira tipos e faixas contra a §9
400connectionId ausente na query onde é exigidoAdicione ?connectionId=<uuid>
400Invalid callback stateO state foi adulterado, expirou o fluxo, ou a chave mestra mudou entre connect e callback
400OAuth provider returned error "..."Erro devolvido pelo Google/Microsoft. A mensagem original vem junto
403Organization context requiredAutentique com token que carregue organizationId
403FORBIDDENFalta CALENDAR_READ, CALENDAR_WRITE ou CALENDAR_ADMINConfira o papel e as permissões contratadas pela organização
404NOT_FOUNDConfig ou conexão inexistente na organização do tokenConfira o ID; recurso de outro tenant também devolve 404
409CONFLICTJá existe config com esse nome na organizaçãoEscolha outro nome
400VALIDATIONCannot delete the only calendar provider configCrie outra config antes de apagar a padrão
400VALIDATIONThis calendar connection has been revokedO usuário precisa reconectar
400VALIDATIONToken expired and no refresh token availableReconecte. A conexão foi marcada TOKEN_EXPIRED
500INTERNALFailed to decrypt connection tokensA CALENDAR_CREDENTIAL_MASTER_KEY mudou. Rotação de chave invalida todas as conexões
500INTERNALCannot compute Outlook busy periods: connection is missing accountEmailReconecte a conta Outlook

Observabilidade.

  • GET /calendar/health responde vida e versão do build. É sonda de liveness, não de dependência — não testa banco nem provedor.
  • CalendarWebhookDeliveryLog guarda cada tentativa de entrega com status, código HTTP, mensagem de erro e nextRetryAt. É a tabela para responder "o cliente recebeu?".
  • CalendarProviderSubscription.expiresAt é o campo a monitorar quando o push for ligado: assinatura vencida significa mudança de agenda não percebida.
  • O SubscriptionRenewalJob loga contagem de renovações a cada hora — quando iniciado, o que hoje não acontece (§15).

14Segurança e compliance

Isolamento entre tenants. Todo caminho de dado passa por organizationId, e ele vem do claim assinado do JWT — nunca do corpo da requisição. Cada router define requireOrganization localmente e devolve 403 quando o claim falta. Nos repositórios, a organização é parte da cláusula de busca: connectionRepo.findById(id, organizationId) e providerConfigRepo.findById(id, organizationId) recebem os dois argumentos, e o serviço trata "não encontrado nesta organização" como 404 — recurso de outro tenant é indistinguível de recurso inexistente.

O ponto mais sensível é o callback OAuth, que é rota pública. Ele não confia em nada da requisição: o organizationId sai da decifragem autenticada do state. Como AES-256-GCM verifica integridade, um state alterado falha antes de virar tenant.

Como as credenciais OAuth são protegidas — evidência no código.

O quêComoOnde
client_id, client_secret, redirect_uri da organizaçãoAES-256-GCM, IV aleatório de 12 bytes por operação, authTag verificado na decifragem. Formato iv:authTag:ciphertext, tudo em hexsrc/calendar/utils/crypto.ts
Access token e refresh token do usuárioMesmo esquema, envelope JSON {accessToken, refreshToken} cifrado antes de gravarconnection.service.ts, na criação e em toda renovação
state do OAuth em trânsitoMesmo esquema. organizationId, userId, configId e redirectUrl nunca trafegam em claroconnect e handleCallback
Chave mestraCALENDAR_CREDENTIAL_MASTER_KEY, 64 hex (32 bytes), validada por regex a cada uso. Vive em SOPS, nunca no repositóriogetMasterKey()

Nada de credencial volta pela API. O toProviderConfigData monta a resposta campo a campo e não inclui credentials. Não existe endpoint que devolva client_secret ou token. A única forma de ver uma credencial em claro é o script operacional scripts/decrypt-calendar-configs.ts, que exige a chave mestra no ambiente e já mascara o client_secret, imprimindo só os 6 primeiros e os 4 últimos caracteres com o comprimento — ou seja, foi escrito assumindo que a saída pode acabar num log.

Chave por módulo, não uma para tudo. CALENDAR_CREDENTIAL_MASTER_KEY é diferente de EMAIL_CREDENTIAL_MASTER_KEY, SSO_CREDENTIAL_MASTER_KEY e das demais. Vazar uma não entrega as outras. O preço é operacional: rotacionar a chave do Calendar invalida todas as configs e conexões existentes, porque não há campo de versão de chave nem recifragem gradual. Rotação hoje é um evento planejado com reconexão de todos os usuários.

Que conteúdo de agenda é armazenado. Esta é a resposta curta e a mais importante para LGPD: o Calendar não guarda evento nenhum. Não existe tabela de eventos. Toda leitura vai ao provedor em tempo real e a resposta é devolvida ao chamador sem persistência. O que fica no banco é:

DadoOndeNatureza
externalEmail da conta conectadacalendar_connectionsDado pessoal
Tokens OAuthcalendar_connectionsCredencial, cifrada
userId e organizationIdVárias tabelasIdentificadores internos
Credenciais do app OAuthcalendar_provider_configsSegredo do cliente, cifrado
payload de notificação entreguecalendar_webhook_delivery_logsPode conter dado de evento — é o que foi enviado ao cliente
resource, clientState, expiresAtcalendar_provider_subscriptionsMetadado de assinatura

A única persistência que pode conter conteúdo de agenda é o payload do log de entrega de webhook. Se a sua política proíbe reter conteúdo de compromisso, essa é a tabela a expurgar.

LGPD. Agenda é dado pessoal, e frequentemente sensível — o assunto de um compromisso revela consulta médica, entrevista de emprego, reunião com advogado. Três pontos de atenção:

  1. Base legal e finalidade. O consentimento OAuth é do titular no provedor, não a base legal do seu tratamento. Documente a finalidade no seu aviso de privacidade e peça só os escopos que você usa. Os escopos padrão do Google já são granulares por esse motivo.
  2. Minimização por não armazenar. Não guardar evento é a decisão de privacidade mais forte do módulo. Preserve-a: não crie cache de evento sem revisar retenção.
  3. Eliminação. DELETE /connections/:id faz exclusão lógica — a linha permanece com deletedAt e os tokens cifrados continuam ali. Desconectar não revoga o token no provedor. Atender a um pedido de eliminação exige expurgo explícito no banco e revogação no lado do Google ou da Microsoft; nenhum dos dois é automatizado hoje (§15).

Retenção. Não há política automática. CalendarWebhookDeliveryLog cresce indefinidamente e não tem rotina de expurgo. Defina a sua antes de ligar entrega de webhook em volume.

Autenticação e permissões.

PermissãoConcede
CALENDAR_READListar configs, conexões, calendários, eventos, webhooks e buscar horários
CALENDAR_WRITEConectar, desconectar, criar/alterar/remover evento e convidados
CALENDAR_ADMINGerenciar configs de provedor e assinaturas de webhook

As duas rotas de notificação do provedor e o callback OAuth são públicas por necessidade — o Google e a Microsoft não carregam token da plataforma. O callback se defende pelo state autenticado. As notificações se defendem por clientState, verificado em provider.verifyNotification antes de qualquer entrega; notificação de assinatura desconhecida é ignorada em silêncio, sem revelar se existe.


15Limitações conhecidas

LimitaçãoImpactoSituação
Push do provedor não está ligadoCalendarProviderSubscriptionService.createSubscription existe, é testado, e nenhuma rota ou job o chama. Não há endpoint para criar assinatura de notificação. Sem assinatura, os callbacks em /providers/notifications/* nunca recebem nada e nenhum webhook de saída disparaPeça construída, não conectada. É o principal motivo do status beta
O job de renovação não é iniciadoSubscriptionRenewalJob tem start(), mas src/calendar/main.ts não o chama. Mesmo que assinaturas fossem criadas manualmente, elas expirariam sem renovaçãoCorreção pequena, ainda não feita
Sem sincronização incrementalO building block não usa syncToken do Google nem delta query do Graph. Toda leitura é uma consulta por janelaRoadmap
Recorrência na criação só chega ao OutlookcreateEventSchema aceita recurrence, e o OutlookProvider o repassa ao Graph. O GoogleProvider não monta recurrence no corpo — criar evento recorrente no Google gera um evento único, sem erroDivergência entre providers. Não anuncie recorrência para Google
Leitura no Google vem com recorrência expandidalistEvents usa singleEvents: true, então cada ocorrência vem separada e não há evento-mãe com regraPor design — mantém o tipo canônico simples
Sem disponibilidade combinada de várias pessoasGET /slots aceita uma connectionId. "Quando A, B e C estão livres juntos" é interseção no seu códigoRoadmap. É o forte do Cronofy
Sem calendário de feriadosweekdaysOnly só descarta sábado e domingo. Feriado nacional é dia útil para o cálculoNão implementado
Sem reserva ou trava de horárioNada impede dois usuários de escolherem o mesmo horário. Free/busy é foto, não reservaPor design — trave do seu lado
Sem cache de free/busyCada GET /slots bate no provedor. É a chamada mais cara e a que mais consome quotaPor design enquanto não houver push para invalidar
Webhook de saída sem retry automáticoO log grava nextRetryAt, mas não há job que releia e retente. Falha de entrega fica registrada e parada. Não usa o Webhooks Engine, que tem retry e assinatura RSA prontosRoadmap — migrar para o Webhooks Engine
GET /webhooks/:id não existePOST, GET (lista), PATCH e DELETE. O serviço tem getById, a rota nãoLacuna simples
Status ERROR nunca é atribuídoCalendarConnectionStatus.ERROR está no enum e nenhum caminho de código o gravaEnum maior que o comportamento
refreshToken guarda o mesmo envelope de accessTokenAs duas colunas recebem a mesma string cifrada. A coluna é redundanteSimplificação deliberada, coluna herdada
Sem expurgo automatizadoCalendarWebhookDeliveryLog cresce sem limite; conexão excluída mantém tokens cifrados; desconectar não revoga no provedorRoadmap — relevante para LGPD (§14)
/health não checa dependênciaResponde vida e versão, sem tocar banco ou provedorSuficiente para liveness, insuficiente para readiness
Prefixo de rota duplicadoA rota real é /calendar/api/v1/calendar/.... Feio e fácil de errarNão corrigido para não quebrar integrações existentes

16Perguntas frequentes

Vocês guardam a agenda dos meus usuários?

Não. Não existe tabela de eventos no Calendar. Toda leitura vai ao Google ou à Microsoft no momento da chamada e a resposta é devolvida sem persistência. O que fica no banco é o e-mail da conta conectada, os tokens cifrados e o metadado da conexão. A única exceção é o payload do log de entrega de webhook, quando você usa webhook de saída — está detalhado em §14.

Preciso criar um app OAuth para cada building block que fala com o Google?

Não. O mesmo client_id serve para Calendar e Email; muda o redirect_uri e o conjunto de escopos. Há inclusive um script que copia as credenciais do Email para o Calendar (§11). Só lembre de habilitar cada API no projeto do Google — app com Gmail ligado e Calendar desligado conecta e depois falha na primeira chamada de agenda.

Por que a rota tem calendar duas vezes?

Porque o app Hono usa basePath('/calendar') e os routers são montados sob /api/v1/calendar. A rota efetiva é /calendar/api/v1/calendar/events. Não foi corrigido para não quebrar quem já integrou. Copie as rotas da §9 na íntegra.

O GET /slots garante que o horário ainda estará livre quando eu marcar?

Não. Ele é uma foto do free/busy naquele instante e o provedor não reserva nada. Se houver concorrência do seu lado — dois clientes escolhendo horário do mesmo profissional ao mesmo tempo —, mantenha um lock na sua tabela antes de chamar POST /events. Rebusque os horários se passou tempo entre a exibição e a escolha.

Recebo notificação quando alguém mexe na agenda pelo celular?

Hoje não. A infraestrutura está construída — modelo de assinatura, verificação de clientState, parsing de notificação dos dois provedores, job de renovação — mas não há endpoint que crie a assinatura no provedor e o job não é iniciado. Enquanto isso, use GET /events por polling na janela que interessa (§11). Isso está declarado em §15 e é o motivo do status beta.

Suporta Apple Calendar ou CalDAV?

Não. Só GOOGLE e OUTLOOK. A interface CalendarProvider foi feita para receber outros — quem implementá-la ganha os endpoints e o motor de horários de graça —, mas hoje são dois. Se CalDAV é requisito hoje, Nylas e Cronofy cobrem.

Como consulto a disponibilidade de três pessoas ao mesmo tempo?

Chamando GET /slots uma vez por connectionId e cruzando os resultados no seu código. Não há endpoint de disponibilidade combinada (§15). Se esse é o problema central do seu produto, o Cronofy foi construído para isso e nós não fomos.

Por que os horários voltam sem Z no final?

Porque estão no fuso que você pediu, não em UTC. 2026-09-01T09:00:00.000 com timeZone=America/Sao_Paulo é nove da manhã em São Paulo. Trate como horário local do fuso pedido e devolva no mesmo formato ao criar o evento. Interpretar como UTC é o erro de integração mais comum aqui.

O que acontece se eu rotacionar a CALENDAR_CREDENTIAL_MASTER_KEY?

Todas as configs de provedor e todas as conexões deixam de decifrar, com Failed to decrypt connection tokens. Não há versionamento de chave nem recifragem gradual. Rotação é evento planejado, com recadastro das configs e reconexão de todos os usuários. Guarde a chave em SOPS e trate como segredo de longa duração.

Desconectar apaga o acesso do lado do Google?

Não. DELETE /connections/:id é exclusão lógica no nosso banco: a linha fica com deletedAt e os tokens cifrados permanecem. A autorização continua ativa no provedor até o usuário revogá-la na conta dele ou até você chamar a API de revogação do provedor. Para atender pedido de eliminação sob LGPD, os dois passos precisam ser feitos explicitamente (§14, §15).


Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md

Building blocks relacionados