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
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.
211 lines
7.1 KiB
HTTP
211 lines
7.1 KiB
HTTP
@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). Só 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 já 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. Só 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). Só 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 só
|
||
### 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
|
||
}
|