Files
schdlr/request.http
Alexandre Possebom 1df9e81ec1
Build and Push Docker Image / build (push) Successful in 4m33s
Continuous integration / Check (push) Successful in 1m40s
Continuous integration / Test Suite (push) Successful in 2m34s
Continuous integration / Rustfmt (push) Successful in 27s
Continuous integration / Clippy (push) Successful in 1m35s
feat(calendar): publish a professional's agenda as a feed
The Professional subscribes to their own agenda in Apple Calendar or
Google Calendar through a secret URL that serves iCalendar (RFC 5545).
No OAuth, no writing into their account: the feed is the complete state,
so cancelling makes the event disappear on the next fetch. See ADR 0006.

- `professionals.calendar_token`, nullable and UNIQUE, born on the first
  request through the same idempotent COALESCE as the access code.
  26 symbols of the existing unambiguous alphabet, ~129 bits: this URL
  lives for years on someone else's server.
- `src/icalendar.rs` is pure, like `availability.rs`: folding at 75
  octets without splitting a UTF-8 sequence, TEXT escaping, UTC
  instants, stable UID so rescheduling moves the event. Do not copy the
  frontend's `ics.ts`, which never folds.
- `DTSTAMP` is the appointment's `updated_at`, never `now()`: with the
  request time the body would change on every fetch and the ETag would
  never match, so the 304 would not exist. Apple fetches hourly per
  subscriber.
- `confirmed`, `completed` and `no_show` only, from 90 days back into
  the open future. The status list is a Rust constant handed to the
  repository instead of a literal buried in SQL, so the ADR decision is
  readable and testable.
- The feed answers the same 404 for an unknown token and for a
  deactivated Professional, and the three panel routes go through
  `require_manage_professional`.

The public route is `/calendar/{token}/feed.ics`, not `{token}.ics`:
matchit 0.8 does not support dynamic suffixes, and the other shape
panics the Router at boot instead of failing to compile.

24 unit tests and one against Postgres, which is where the status filter
and the window actually run. Also verified end to end against a real
server: 3 events out of 8, a client name with `;` and `,` escaped, no
line over 75 octets, 304 on `if-none-match`, rotation killing the old
URL, and the email landing in mailpit.

Two ROADMAP debt entries were stale and are corrected with the evidence:
clippy passes clean and the dbml is current.
2026-07-30 09:21:10 -03:00

211 lines
7.1 KiB
HTTP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@baseUrl = http://localhost:3000/api/v1
# Senha do seu usuário local; o seed cria alexandre@possebom.com como super-admin.
@email = alexandre@possebom.com
@password = suasenha
### Login (rate limit: burst de 10, depois 1 a cada 2s)
# @name login
POST {{baseUrl}}/auth/login HTTP/1.1
content-type: application/json
{
"email": "{{email}}",
"password": "{{password}}"
}
###
@authToken = {{login.response.body.access_token}}
@refreshToken = {{login.response.body.refresh_token}}
### Renova o par de tokens (só aceita o refresh_token, não o access)
POST {{baseUrl}}/auth/refresh HTTP/1.1
content-type: application/json
{
"refresh_token": "{{refreshToken}}"
}
### Perfil de quem está logado
GET {{baseUrl}}/me HTTP/1.1
authorization: Bearer {{authToken}}
### Empresas (super-admin)
GET {{baseUrl}}/companies HTTP/1.1
authorization: Bearer {{authToken}}
### Profissionais da empresa 1
GET {{baseUrl}}/professional/find_by_company_id/1 HTTP/1.1
authorization: Bearer {{authToken}}
### Criar profissional (company_id é obrigatório)
POST {{baseUrl}}/professional HTTP/1.1
authorization: Bearer {{authToken}}
content-type: application/json
{
"name": "Maria Silva",
"email": "maria@exemplo.com",
"password": "troque-me",
"phone": "41999999999",
"role": "professional",
"company_id": 1
}
### Serviços da empresa 1
GET {{baseUrl}}/services/1 HTTP/1.1
authorization: Bearer {{authToken}}
### Criar serviço. "icon" é o slug do ícone no lucide.dev (kebab-case),
### opcional: ausente grava '' (sem ícone). o tamanho (máx. 40) é validado;
### a lista de ícones válidos vive no front. No PATCH, "icon": "" limpa.
POST {{baseUrl}}/service HTTP/1.1
authorization: Bearer {{authToken}}
content-type: application/json
{
"company_id": 1,
"name": "Corte",
"duration_minutes": 30,
"price_cents": 5000,
"icon": "scissors"
}
### Serviços que o profissional 1 oferece (price_cents = base do serviço;
### override_price_cents = preço próprio dele, null = cobra o base)
GET {{baseUrl}}/professional/1/services HTTP/1.1
authorization: Bearer {{authToken}}
### Vincular serviços ao profissional, formato novo (autoritativo): entrada
### sem price_cents limpa o override daquele vínculo.
PUT {{baseUrl}}/professional/1/services HTTP/1.1
authorization: Bearer {{authToken}}
content-type: application/json
{
"services": [
{ "service_id": 1, "price_cents": 2000 },
{ "service_id": 2 }
]
}
### O mesmo PUT no formato legado (frontend atual): troca o conjunto de ids e
### PRESERVA o override de preço de quem permanece.
PUT {{baseUrl}}/professional/1/services HTTP/1.1
authorization: Bearer {{authToken}}
content-type: application/json
{
"service_ids": [1, 2]
}
### Grade de um profissional
GET {{baseUrl}}/professional/1/working_hours HTTP/1.1
authorization: Bearer {{authToken}}
### Slots livres: profissional 1, serviço 1, numa data
GET {{baseUrl}}/availability/1/1/2030-09-01 HTTP/1.1
authorization: Bearer {{authToken}}
### Calendar feed do profissional 1 (ADR 0006): devolve url e webcal_url,
### gerando o token no primeiro pedido. Exige gerenciar aquele profissional
### (ele mesmo ou o admin da empresa dele).
GET {{baseUrl}}/professional/1/calendar_feed HTTP/1.1
authorization: Bearer {{authToken}}
### Troca o token do feed. É a única revogação: a URL antiga para de responder,
### e quem entregou o link ao Google não o recupera.
POST {{baseUrl}}/professional/1/calendar_feed/rotate HTTP/1.1
authorization: Bearer {{authToken}}
### Manda o link para o e-mail do próprio profissional como ele abre no
### computador, que é o único lugar onde o Google assina calendário por URL).
### 400 se o profissional não tem e-mail cadastrado.
POST {{baseUrl}}/professional/1/calendar_feed/email HTTP/1.1
authorization: Bearer {{authToken}}
### Criar agendamento (com service_id, o start precisa ser um slot da grade)
POST {{baseUrl}}/appointment HTTP/1.1
authorization: Bearer {{authToken}}
content-type: application/json
{
"start": "2030-09-01T13:00:00Z",
"end": "2030-09-01T14:00:00Z",
"professional_id": 1,
"client_id": 1,
"company_id": 1,
"service_id": 1
}
### ---- Rotas públicas (sem token; rate limit por IP) ----
### Página da empresa pelo slug
GET {{baseUrl}}/public/possebom HTTP/1.1
### Serviços ativos da empresa. price_cents = MENOR preço resolvido entre os
### profissionais ativos que oferecem (base se ninguém oferece);
### price_varies = tem profissional cobrando diferente ("a partir de").
GET {{baseUrl}}/public/possebom/services HTTP/1.1
### Quem atende o serviço 1, cada um com o preço resolvido dele (override ou base)
GET {{baseUrl}}/public/possebom/service/1/professionals HTTP/1.1
### A equipe da casa, para o wizard que abre pelo profissional (booking_flow =
### professional_first). Sem preço: ele é do par serviço × profissional. sai
### quem está ativo E oferece algum serviço ativo.
GET {{baseUrl}}/public/possebom/professionals HTTP/1.1
### Os serviços do profissional 1, com o preço FECHADO dele (override ou base).
### Sem price_varies: aqui não existe "a partir de".
GET {{baseUrl}}/public/possebom/professional/1/services HTTP/1.1
### Slots públicos
GET {{baseUrl}}/public/possebom/professional/1/service/1/slots/2030-09-01 HTTP/1.1
### Quais dias têm horário livre, para a fita da página pública.
### `days` é opcional (padrão 14, máximo 31). o has_slots: os horários
### do dia continuam vindo da rota de slots acima.
GET {{baseUrl}}/public/possebom/professional/1/service/1/days/2030-09-01?days=14 HTTP/1.1
### Agendamento público (cliente não autentica). Nasce PENDING (ADR 0004):
### channel "email" (ou ausente) manda o e-mail com o link de confirmação;
### channel "whatsapp" devolve wa_link para o CLIENTE mandar o código.
### "email" é obrigatório no canal email e opcional no whatsapp (alimenta
### o lembrete; se vier, é validado).
POST {{baseUrl}}/public/possebom/appointment HTTP/1.1
content-type: application/json
{
"professional_id": 1,
"service_id": 1,
"start": "2030-09-01T13:00:00Z",
"name": "Cliente Exemplo",
"phone": "41988887777",
"email": "cliente@exemplo.com",
"channel": "whatsapp"
}
### Ver/cancelar aceitam o código curto (ADR 0005) OU o JWT legado no {token}:
### GET {{baseUrl}}/public/appointment/K7MQ2ZVPX3HD
### Confirmação pelo link do e-mail (token de purpose appointment_confirm)
POST {{baseUrl}}/public/appointment/confirm/TOKEN_DO_EMAIL HTTP/1.1
### O Calendar feed em si (ADR 0006): quem autentica é o token da URL, porque
### aplicativo de calendário não manda cabeçalho. Pegue o token no
### GET /professional/1/calendar_feed acima. Repetir com
### `if-none-match: "<etag da resposta>"` tem que devolver 304 sem corpo.
GET {{baseUrl}}/public/calendar/TOKEN_DO_FEED/feed.ics HTTP/1.1
### Webhook da whatsapp-api (fora de /public; secret compartilhado, não JWT)
POST {{baseUrl}}/webhooks/whatsapp HTTP/1.1
content-type: application/json
x-webhook-secret: {{whatsappWebhookSecret}}
{
"message_id": "3EB0EXEMPLO",
"sender_phone": "5541988887777",
"text": "Ola! Quero confirmar meu agendamento. Codigo: AG-XXXXXX",
"is_group": false
}