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.
- 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
- 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
- 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.
| Atributo | Valor |
|---|---|
| Identificador | calendar |
| Categoria | Produtividade |
| Escopo | Tenant (exige organizationId no token) |
| Porta (standalone) | 3019 |
| Path alias | @calendar |
| Prefixo HTTP | /calendar/api/v1/calendar — sim, calendar aparece duas vezes |
| Status | Beta desde 2026-02 |
| Depende de | PostgreSQL (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
| Antes | Depois |
|---|---|
| Duas integrações — Google e Microsoft — com dois modelos de evento | Um tipo canônico de evento; o providerType é detalhe da conexão |
| Free/busy cru devolvido para o app do cliente resolver | GET /slots devolve horários livres já filtrados por expediente, fuso e dia útil |
| Refresh token do usuário final guardado como der | AES-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ódigo | CalendarProviderConfig por organização, com credenciais cifradas e teste de configuração |
| Aritmética de intervalo escrita de novo em cada produto | Quatro 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ério | Catalisa Calendar | Nylas | Cronofy | Cal.com | API direta |
|---|---|---|---|---|---|
| Provedores suportados | Google e Outlook | Google, Microsoft, IMAP/CalDAV e outros | Google, Microsoft, Apple e outros | Vários, via integrações | Um por integração |
| Modelo de preço | Em definição — não é por conta conectada | Por conta conectada/mês (Calendar Only a partir de US$ 10/mês) | Por usuário ativo, valor não público | Por usuário/mês (US$ 12 a US$ 28 no anual) | Sem licença hoje |
| Cálculo de disponibilidade | Sim, com 4 estratégias, expediente e fuso | Sim | Sim, o mais completo do grupo | Sim, é o produto | Você escreve |
| Interface de agendamento pronta | Não | Não | Não | Sim | Não |
| OAuth por organização (app do próprio cliente) | Sim, CalendarProviderConfig por tenant | Via app do Nylas | Sim | Depende do plano | Você constrói |
| Onde o dado de agenda trafega | Dentro da plataforma do cliente | Pelo Nylas | Pelo Cronofy | Pelo Cal.com (ou auto-hospedado) | Direto |
| Push do provedor em tempo real | Construído, não ligado (§15) | Sim | Sim | Sim | Você constrói |
| Recorrência (RRULE) | Delegada ao provedor; criação com recorrência só no Outlook (§15) | Sim | Sim | Sim | Sim |
| Disponibilidade de várias pessoas combinada | Não (§15) | Sim | Sim, é o forte deles | Sim | Você 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
- O tipo canônico é o contrato, e ele é estreito de propósito.
CanonicalCalendarEventtem 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. - 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.
- O app OAuth é do cliente, não nosso. Cada organização registra o próprio
client_ideclient_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. - As correções de fuso estão no código, com o porquê escrito ao lado. O
Prefer: outlook.timezonenogetSchedulee a recusa em usar o literalmepara 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:
| Driver | Por que pesa |
|---|---|
| Contas de calendário conectadas | Cada 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ários | GET /slots chama free/busy no provedor a cada requisição, sem cache; é a chamada mais cara do building block |
| Eventos criados ou alterados | Escrita 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 Calendar | Nylas (Calendar Only) | API direta (Google + Graph) | |
|---|---|---|---|
| Base de cálculo | Em definição | US$ 10/mês com 5 contas, depois US$ 1,50 por conta/mês | Sem licença hoje |
| Ordem de grandeza mensal de licença | A definir | Ordem de US$ 450/mês nesse volume | US$ 0 |
| Cálculo de disponibilidade | Incluso | Incluso | Você escreve |
| Duas integrações para manter | Não | Não | Sim |
| Renovação de assinatura push | Arquitetura pronta, não ligada (§15) | Inclusa | Você 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
stepMsnoSlotsService. 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. OgetScheduledo Graph exige endereço SMTP emschedules. O literalmenã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 oaccountEmailestá ausente, em vez de devolver agenda vazia.Prefer: outlook.timezonenogetSchedule. 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 paraT00:00:00–T23: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 colunaaccessTokenquanto pararefreshToken. Guardar um envelope só evita dois estados de cifra fora de sincronia — o preço é que a colunarefreshTokené redundante hoje. - O
statedo OAuth é um envelope cifrado, não um identificador de sessão.organizationId,userId,configIderedirectUrlviajam cifrados com AES-256-GCM dentro do própriostate. 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, umstateadulterado falha a decifragem e a requisição morre emInvalid callback state. GET /events/:id,DELETE /events/:ide as rotas de convidado exigemconnectionIdna 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 retorna400antes 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
| Termo | Significa |
|---|---|
| Provider config | O 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. |
| Connection | O 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 subscription | A assinatura de notificação push registrada no provedor (channel do Google, subscription do Graph). Expira e precisa ser renovada. |
| Webhook subscription | O caminho inverso: para onde nós entregamos o evento, no sistema do cliente. Assinada com HMAC-SHA256. |
| Canonical event | Representação de evento independente de provedor. É o que a API devolve, sempre. |
| Busy period | Bloco de tempo ocupado devolvido pelo provedor. Insumo do cálculo, não resposta ao cliente. |
| Slot | Horário livre calculado — início e fim, no fuso pedido. É o que se oferece ao usuário final. |
| Algorithm | Estratégia de escolha entre os horários livres candidatos: nextAvailable, randomize, spread, consecutive. |
| Client state | Segredo 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 Prisma | Tabela | Propósito | Campos-chave |
|---|---|---|---|
CalendarProviderConfig | calendar.calendar_provider_configs | App OAuth da organização | credentials (cifrado), providerType, isDefault, isActive, único (organizationId, name) |
CalendarConnection | calendar.calendar_connections | Conta de calendário conectada por um usuário | externalEmail, accessToken e refreshToken (cifrados), tokenExpiresAt, status |
CalendarProviderSubscription | calendar.calendar_provider_subscriptions | Assinatura push registrada no provedor | externalId (único), resource, clientState, expiresAt, isActive |
CalendarWebhookSubscription | calendar.calendar_webhook_subscriptions | Para onde entregamos evento no cliente | callbackUrl, events[], secret, isActive |
CalendarWebhookDeliveryLog | calendar.calendar_webhook_delivery_logs | Histórico de cada tentativa de entrega | status, statusCode, retryCount, nextRetryAt, payload |
Enumerações
| Enum | Valores |
|---|---|
CalendarProviderType | GOOGLE · OUTLOOK |
CalendarConnectionStatus | ACTIVE · TOKEN_EXPIRED · REVOKED · ERROR |
CalendarWebhookEventType | EVENT_CREATED · EVENT_UPDATED · EVENT_DELETED · EVENT_CANCELLED |
CalendarDeliveryStatus | PENDING · SUCCESS · FAILED · RETRYING |
SlotAlgorithm | nextAvailable · 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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /calendar/api/v1/calendar/provider-configs | Cria a config OAuth da organização | CALENDAR_ADMIN |
GET | /calendar/api/v1/calendar/provider-configs | Lista as configs da organização | CALENDAR_READ |
GET | /calendar/api/v1/calendar/provider-configs/:id | Busca uma config | CALENDAR_READ |
PATCH | /calendar/api/v1/calendar/provider-configs/:id | Atualiza a config | CALENDAR_ADMIN |
DELETE | /calendar/api/v1/calendar/provider-configs/:id | Exclusão lógica. 204 | CALENDAR_ADMIN |
POST | /calendar/api/v1/calendar/provider-configs/:id/test | Valida se as credenciais montam um provider | CALENDAR_ADMIN |
Conexões — /calendar/api/v1/calendar/connections
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /calendar/api/v1/calendar/connections/connect | Devolve a URL de consentimento do provedor | CALENDAR_WRITE |
GET | /calendar/api/v1/calendar/connections/callback | Callback OAuth. Rota pública — sem authMiddleware | Pública |
GET | /calendar/api/v1/calendar/connections | Lista as conexões do usuário do token | CALENDAR_READ |
DELETE | /calendar/api/v1/calendar/connections/:id | Desconecta. Exclusão lógica. 204 | CALENDAR_WRITE |
Calendários e horários
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
GET | /calendar/api/v1/calendar/calendars?connectionId= | Lista os calendários da conta conectada | CALENDAR_READ |
GET | /calendar/api/v1/calendar/slots | Calcula horários livres | CALENDAR_READ |
Eventos — /calendar/api/v1/calendar/events
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
GET | /calendar/api/v1/calendar/events | Lista eventos numa janela | CALENDAR_READ |
GET | /calendar/api/v1/calendar/events/:id?connectionId= | Busca um evento | CALENDAR_READ |
POST | /calendar/api/v1/calendar/events | Cria evento. 201 | CALENDAR_WRITE |
PATCH | /calendar/api/v1/calendar/events/:id | Atualiza evento | CALENDAR_WRITE |
DELETE | /calendar/api/v1/calendar/events/:id?connectionId= | Remove evento. 204 | CALENDAR_WRITE |
POST | /calendar/api/v1/calendar/events/:id/attendees | Adiciona convidados | CALENDAR_WRITE |
DELETE | /calendar/api/v1/calendar/events/:id/attendees/:email?connectionId= | Remove um convidado | CALENDAR_WRITE |
Webhooks de saída — /calendar/api/v1/calendar/webhooks
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /calendar/api/v1/calendar/webhooks | Cria assinatura de entrega. 201 | CALENDAR_ADMIN |
GET | /calendar/api/v1/calendar/webhooks | Lista assinaturas | CALENDAR_READ |
PATCH | /calendar/api/v1/calendar/webhooks/:id | Atualiza assinatura | CALENDAR_ADMIN |
DELETE | /calendar/api/v1/calendar/webhooks/:id | Remove assinatura. 204 | CALENDAR_ADMIN |
Notificações do provedor — rotas públicas
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /calendar/api/v1/calendar/providers/notifications/outlook | Callback do Microsoft Graph. Devolve validationToken em texto puro quando presente; senão 202 | Pública |
POST | /calendar/api/v1/calendar/providers/notifications/google | Callback do Google (X-Goog-Channel-ID). 200 | Pública |
Saúde
| Método | Rota | Descrição |
|---|---|---|
GET | /calendar/health | Sonda 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.
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
connectionId | string (UUID) | Sim | Conexão a consultar |
calendarId | string | Não | Calendário específico. Omitido, usa o primário da conta |
startDate | string | Sim | Início da janela. Aceita 2026-09-01 ou ISO com hora |
endDate | string | Sim | Fim da janela. Data pura vira 23:59:59.999 no fuso pedido |
duration | number | Sim | Duração do horário em minutos. 5 a 480 |
timeZone | string | Sim | IANA, ex.: America/Sao_Paulo. Define o expediente e o formato de saída |
quantity | number | Não | Quantos horários devolver. 1 a 100, padrão 10 |
algorithm | enum | Não | nextAvailable (padrão) · randomize · spread · consecutive |
periodStart | string | Não | Início do expediente, HH:mm. Omitido, o dia começa à meia-noite |
periodEnd | string | Não | Fim do expediente, HH:mm. Omitido, o dia vai até a meia-noite seguinte |
weekdaysOnly | boolean | Não | true descarta sábado e domingo. Padrão false |
As quatro estratégias
| Valor | O que faz | Quando usar |
|---|---|---|
nextAvailable | Os quantity primeiros horários, do mais próximo ao mais distante | Padrão. "O quanto antes" |
randomize | Amostra aleatória do conjunto livre, devolvida em ordem cronológica | Distribuir demanda entre horários sem viés pela manhã |
spread | Distribui pelos dias, um por dia enquanto der | Dar opção em dias diferentes, não três horários da mesma terça |
consecutive | Busca o bloco de horários mais grudados entre si | Evitar 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.000comtimeZone=America/Sao_Paulosignifica nove da manhã em São Paulo. Não trate como UTC.
Erros
| Status | Quando |
|---|---|
400 | Query reprovada no Zod — duration fora de 5–480, periodStart fora de HH:mm, connectionId não é UUID |
403 | Token sem organizationId, ou sem CALENDAR_READ |
404 | connectionId não existe na organização do token |
500 | Conexã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"]
}
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
configId | string (UUID) | Sim | Qual CalendarProviderConfig usar |
redirectUrl | string (URL) | Sim | Para onde o usuário volta depois do callback. Viaja cifrado no state |
scopes | string[] | Não | Sobrescreve 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
| Provedor | Escopos |
|---|---|
GOOGLE | calendar.events · calendar.freebusy · calendar.calendarlist.readonly · calendar.calendars.readonly · userinfo.email · openid |
OUTLOOK | User.Read · Calendars.ReadWrite · offline_access |
Os escopos do Google são granulares de propósito, e não o escopo
calendarcompleto: assim a verificação OAuth do Google não exige avaliação CASA de escopo restrito. Trocar porcalendarinteiro 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
| Status | Quando |
|---|---|
400 | Corpo reprovado no Zod, ou providerType da config não é GOOGLE nem OUTLOOK |
403 | Sem CALENDAR_WRITE ou sem organização no token |
404 | configId 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" }
]
}
| Campo | Tipo | Obrigatório | Padrão |
|---|---|---|---|
connectionId | string (UUID) | Sim | — |
calendarId | string | Não | Calendário primário da conta |
subject | string (mín. 1) | Sim | — |
body | string | Não | — |
bodyContentType | text | html | Não | text |
location | string | Não | — |
start / end | string | Sim | — |
timeZone | string (IANA) | Sim | — |
isAllDay | boolean | Não | false |
isOnlineMeeting | boolean | Não | false |
attendees[] | { email, name?, type? } | Não | type = required |
recurrence | object | Não | Aceito 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
| Status | Quando |
|---|---|
400 | Corpo reprovado no Zod |
403 | Sem CALENDAR_WRITE ou sem organização |
404 | connectionId inexistente na organização |
500 | Token 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çalho | Conteúdo |
|---|---|
X-Webhook-Signature | sha256=<HMAC-SHA256 do corpo bruto, com o seu secret> |
X-Webhook-Event | O CalendarWebhookEventType |
X-Webhook-Id | Identificador 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_secretde 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_urido 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 emstart/endexatamente como vieram, com o mesmotimeZone, 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}'
| Sintoma | Causa provável | O que fazer |
|---|---|---|
data: [] e a agenda tem buracos evidentes | Janela periodStart–periodEnd menor que a duration pedida | Aumente 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 pedido | Confira o fuso; 2026-09-01 em America/Sao_Paulo não é o mesmo dia em UTC |
data: [] com weekdaysOnly=true | A janela caiu inteira em fim de semana ou feriado prolongado | O building block não conhece feriado — ver §15 |
| Horários que estão ocupados aparecem como livres, no Outlook | Conexão sem externalEmail | Reconecte a conta; o free/busy do Graph exige o endereço SMTP e o provider recusa sem ele |
500 com falha de free/busy | Escopo insuficiente na conexão | Reconecte 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 /slotscomo detector de mudança. Ele é a chamada mais cara do módulo. UseGET /eventsna janela que interessa. pageSizeé limitado a 100. Usemeta.nextPageTokenpara 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 /webhooksnã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 block | Como se relaciona | Obrigatório |
|---|---|---|
| IAM | Emite o token com organizationId e as permissões CALENDAR_* | Sim |
| Mesmo padrão de integração OAuth — ver abaixo. Compartilha o app OAuth do provedor | Não | |
| Webhooks Engine | Entrega de eventos com assinatura, retry e log. O Calendar hoje entrega por conta própria (§15) | Não |
| SSO | Federação de identidade. Também guarda credenciais de provedor cifradas com chave mestra própria | Não |
| Audit Trail | Registro de quem conectou, desconectou ou alterou config de provedor | Nã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ável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
CALENDAR_CREDENTIAL_MASTER_KEY | Chave AES-256-GCM que cifra credenciais e tokens. 64 caracteres hex (32 bytes). Gere com openssl rand -hex 32 | Sim, para usar o módulo | — |
DATABASE_URL | PostgreSQL, schema calendar | Sim | — |
JWT_SECRET | Compartilhado com o IAM, mínimo 44 caracteres | Sim | — |
MODULE_CALENDAR_URL | URL interna do serviço em standalone | Em standalone | '' |
MODULE_IAM_URL | URL interna do IAM | Em standalone | — |
DEPLOYMENT_MODE | monolith ou standalone | Não | monolith |
PORT | Porta do processo. No compose local, mapeada para 3019 | Não | 3000 |
A chave mestra é validada no formato só quando usada —
getMasterKey()lê direto deprocess.enve exige 64 caracteres hex. Uma chave malformada não derruba o boot; derruba a primeira operação que precise cifrar ou decifrar. Verifique comPOST /provider-configslogo após o deploy.
Dependências de infraestrutura
| Dependência | Para quê |
|---|---|
| PostgreSQL | Schema calendar, 5 tabelas |
| IAM | Emissão e validação do token |
| Google Calendar API | Eventos, free/busy, watch |
| Microsoft Graph v1.0 | Eventos, getSchedule, subscriptions |
Limites e quotas
| Limite | Valor | Origem |
|---|---|---|
Duração do horário em /slots | 5 a 480 minutos | Schema Zod |
| Quantidade de horários por busca | 1 a 100, padrão 10 | Schema Zod |
| Passo da varredura de horários | 15 minutos, fixo | SlotsService |
pageSize na listagem de eventos | 1 a 100, padrão 50 | Schema Zod |
| Tempo limite de entrega de webhook | 30 segundos | CalendarWebhookService |
secret do webhook | Mínimo 16 caracteres | Schema Zod |
| Expiração pedida na assinatura do provedor | 4.230 minutos (~2,9 dias) | Constante no código |
| Google Calendar API | 10.000 req/min por projeto, 600 req/min por usuário, 1.000.000/dia | Documentação do Google, 2026-08-16 |
Assinatura de event no Graph | Máximo 10.080 minutos; 1.000 assinaturas ativas por caixa | Documentaçã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
| Status | Código | Significa | O que fazer |
|---|---|---|---|
400 | VALIDATION | Corpo ou query reprovados no Zod | Confira tipos e faixas contra a §9 |
400 | — | connectionId ausente na query onde é exigido | Adicione ?connectionId=<uuid> |
400 | — | Invalid callback state | O state foi adulterado, expirou o fluxo, ou a chave mestra mudou entre connect e callback |
400 | — | OAuth provider returned error "..." | Erro devolvido pelo Google/Microsoft. A mensagem original vem junto |
403 | — | Organization context required | Autentique com token que carregue organizationId |
403 | FORBIDDEN | Falta CALENDAR_READ, CALENDAR_WRITE ou CALENDAR_ADMIN | Confira o papel e as permissões contratadas pela organização |
404 | NOT_FOUND | Config ou conexão inexistente na organização do token | Confira o ID; recurso de outro tenant também devolve 404 |
409 | CONFLICT | Já existe config com esse nome na organização | Escolha outro nome |
400 | VALIDATION | Cannot delete the only calendar provider config | Crie outra config antes de apagar a padrão |
400 | VALIDATION | This calendar connection has been revoked | O usuário precisa reconectar |
400 | VALIDATION | Token expired and no refresh token available | Reconecte. A conexão foi marcada TOKEN_EXPIRED |
500 | INTERNAL | Failed to decrypt connection tokens | A CALENDAR_CREDENTIAL_MASTER_KEY mudou. Rotação de chave invalida todas as conexões |
500 | INTERNAL | Cannot compute Outlook busy periods: connection is missing accountEmail | Reconecte a conta Outlook |
Observabilidade.
GET /calendar/healthresponde vida e versão do build. É sonda de liveness, não de dependência — não testa banco nem provedor.CalendarWebhookDeliveryLogguarda cada tentativa de entrega com status, código HTTP, mensagem de erro enextRetryAt. É 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
SubscriptionRenewalJobloga 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ê | Como | Onde |
|---|---|---|
client_id, client_secret, redirect_uri da organização | AES-256-GCM, IV aleatório de 12 bytes por operação, authTag verificado na decifragem. Formato iv:authTag:ciphertext, tudo em hex | src/calendar/utils/crypto.ts |
| Access token e refresh token do usuário | Mesmo esquema, envelope JSON {accessToken, refreshToken} cifrado antes de gravar | connection.service.ts, na criação e em toda renovação |
state do OAuth em trânsito | Mesmo esquema. organizationId, userId, configId e redirectUrl nunca trafegam em claro | connect e handleCallback |
| Chave mestra | CALENDAR_CREDENTIAL_MASTER_KEY, 64 hex (32 bytes), validada por regex a cada uso. Vive em SOPS, nunca no repositório | getMasterKey() |
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 é:
| Dado | Onde | Natureza |
|---|---|---|
externalEmail da conta conectada | calendar_connections | Dado pessoal |
| Tokens OAuth | calendar_connections | Credencial, cifrada |
userId e organizationId | Várias tabelas | Identificadores internos |
| Credenciais do app OAuth | calendar_provider_configs | Segredo do cliente, cifrado |
payload de notificação entregue | calendar_webhook_delivery_logs | Pode conter dado de evento — é o que foi enviado ao cliente |
resource, clientState, expiresAt | calendar_provider_subscriptions | Metadado 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:
- 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.
- 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.
- Eliminação.
DELETE /connections/:idfaz exclusão lógica — a linha permanece comdeletedAte 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ão | Concede |
|---|---|
CALENDAR_READ | Listar configs, conexões, calendários, eventos, webhooks e buscar horários |
CALENDAR_WRITE | Conectar, desconectar, criar/alterar/remover evento e convidados |
CALENDAR_ADMIN | Gerenciar 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ção | Impacto | Situação |
|---|---|---|
| Push do provedor não está ligado | CalendarProviderSubscriptionService.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 dispara | Peça construída, não conectada. É o principal motivo do status beta |
| O job de renovação não é iniciado | SubscriptionRenewalJob tem start(), mas src/calendar/main.ts não o chama. Mesmo que assinaturas fossem criadas manualmente, elas expirariam sem renovação | Correção pequena, ainda não feita |
| Sem sincronização incremental | O building block não usa syncToken do Google nem delta query do Graph. Toda leitura é uma consulta por janela | Roadmap |
| Recorrência na criação só chega ao Outlook | createEventSchema 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 erro | Divergência entre providers. Não anuncie recorrência para Google |
| Leitura no Google vem com recorrência expandida | listEvents usa singleEvents: true, então cada ocorrência vem separada e não há evento-mãe com regra | Por design — mantém o tipo canônico simples |
| Sem disponibilidade combinada de várias pessoas | GET /slots aceita uma connectionId. "Quando A, B e C estão livres juntos" é interseção no seu código | Roadmap. É o forte do Cronofy |
| Sem calendário de feriados | weekdaysOnly só descarta sábado e domingo. Feriado nacional é dia útil para o cálculo | Não implementado |
| Sem reserva ou trava de horário | Nada impede dois usuários de escolherem o mesmo horário. Free/busy é foto, não reserva | Por design — trave do seu lado |
| Sem cache de free/busy | Cada GET /slots bate no provedor. É a chamada mais cara e a que mais consome quota | Por design enquanto não houver push para invalidar |
| Webhook de saída sem retry automático | O 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 prontos | Roadmap — migrar para o Webhooks Engine |
GET /webhooks/:id não existe | Há POST, GET (lista), PATCH e DELETE. O serviço tem getById, a rota não | Lacuna simples |
Status ERROR nunca é atribuído | CalendarConnectionStatus.ERROR está no enum e nenhum caminho de código o grava | Enum maior que o comportamento |
refreshToken guarda o mesmo envelope de accessToken | As duas colunas recebem a mesma string cifrada. A coluna é redundante | Simplificação deliberada, coluna herdada |
| Sem expurgo automatizado | CalendarWebhookDeliveryLog cresce sem limite; conexão excluída mantém tokens cifrados; desconectar não revoga no provedor | Roadmap — relevante para LGPD (§14) |
/health não checa dependência | Responde vida e versão, sem tocar banco ou provedor | Suficiente para liveness, insuficiente para readiness |
| Prefixo de rota duplicado | A rota real é /calendar/api/v1/calendar/.... Feio e fácil de errar | Nã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