feat(calendar): publish a professional's agenda as a feed
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
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.
This commit is contained in:
+172
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "--sql\n\t\t\tSELECT * FROM professionals WHERE calendar_token = $1\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "email",
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "password",
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "password"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "role",
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "role"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "last_activity",
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "last_activity"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "company_id",
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "company_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "phone",
|
||||
"type_info": "Varchar",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "phone"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "active",
|
||||
"type_info": "Bool",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "active"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "avatar",
|
||||
"type_info": "Varchar",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "avatar"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "password_changed_at",
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "password_changed_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "calendar_token",
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "calendar_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1ca5f229bd9061e40d2ef3c0e9d177e048834a926a714d47074e27b4a5dc06df"
|
||||
}
|
||||
+13
-1
@@ -134,6 +134,17 @@
|
||||
"name": "password_changed_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "calendar_token",
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "calendar_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -153,7 +164,8 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3445bf38bd17742ee05201a318e576a8a50cb3af7caf91c77ba376508c2b593e"
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "--sql\n\t\t\tUPDATE professionals SET calendar_token = COALESCE(calendar_token, $2)\n\t\t\tWHERE id = $1\n\t\t\tRETURNING calendar_token\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "calendar_token",
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "calendar_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "41e1034e411943b3d0e6d1062963516deed4eb6f4e045f8df653a843aa7a4f7a"
|
||||
}
|
||||
+13
-1
@@ -134,6 +134,17 @@
|
||||
"name": "password_changed_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "calendar_token",
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "calendar_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -153,7 +164,8 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6609bcc2c1136b41a51ccd24c7b157ae276011efc5bc039d6522cd8d87d9c1c4"
|
||||
|
||||
+13
-1
@@ -134,6 +134,17 @@
|
||||
"name": "password_changed_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "calendar_token",
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "calendar_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -153,7 +164,8 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "93f38960865c3a62aedcd0fa3d2e255d43fc50b0bf87092a701aeb69e869ee49"
|
||||
|
||||
+13
-1
@@ -134,6 +134,17 @@
|
||||
"name": "password_changed_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "calendar_token",
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "calendar_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -153,7 +164,8 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "96538561e2911b254cc7101b2cd9399dca7d08d800d7c4afba46aa2cda5b6b93"
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "--sql\n\t\t\tSELECT\n\t\t\t\ta.id,\n\t\t\t\ta.\"start\",\n\t\t\t\ta.\"end\",\n\t\t\t\ta.updated_at,\n\t\t\t\ta.status,\n\t\t\t\ta.description,\n\t\t\t\ta.price_cents,\n\t\t\t\tc.name AS client_name,\n\t\t\t\tc.phone AS client_phone,\n\t\t\t\ts.name AS \"service_name?\"\n\t\t\tFROM appointments a\n\t\t\t\tJOIN clients c ON c.id = a.client_id\n\t\t\t\tLEFT JOIN services s ON s.id = a.service_id\n\t\t\tWHERE a.professional_id = $1 AND a.\"start\" >= $2 AND a.status = ANY($3)\n\t\t\tORDER BY a.\"start\"\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "appointments",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "start",
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "appointments",
|
||||
"name": "start"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "end",
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "appointments",
|
||||
"name": "end"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "appointments",
|
||||
"name": "updated_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "appointments",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "description",
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "appointments",
|
||||
"name": "description"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "price_cents",
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "appointments",
|
||||
"name": "price_cents"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "client_name",
|
||||
"type_info": "Varchar",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "clients",
|
||||
"name": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "client_phone",
|
||||
"type_info": "Varchar",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "clients",
|
||||
"name": "phone"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "service_name?",
|
||||
"type_info": "Varchar",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "services",
|
||||
"name": "name"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Timestamptz",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9d78dc3a48e7edd8d04e0a164144b7ad51fe1e48b2cd7f903f5e6fcf29f8dedd"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "--sql\n\t\t\tUPDATE professionals SET calendar_token = $2 WHERE id = $1\n\t\t\tRETURNING calendar_token\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "calendar_token",
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "calendar_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a50910f63f655c72b3c07679867d5039ac58277686e06f3b678775c16f417eba"
|
||||
}
|
||||
+13
-1
@@ -134,6 +134,17 @@
|
||||
"name": "password_changed_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "calendar_token",
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "professionals",
|
||||
"name": "calendar_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -153,7 +164,8 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f00900f6693ff07ad2fdff90350036ceebe00d60287e7ac20b5aef3911b2c55d"
|
||||
|
||||
@@ -28,8 +28,8 @@ ambiente o processo dá panic no primeiro uso do token, de propósito.
|
||||
|
||||
| Comando | Para quê |
|
||||
|---|---|
|
||||
| `SQLX_OFFLINE=true cargo test --bins` | 310 testes unitários, não precisa de banco |
|
||||
| `cargo test --test db_constraints` | 18 testes de constraint, **exigem Postgres** e `DATABASE_URL` |
|
||||
| `SQLX_OFFLINE=true cargo test --bins` | 361 testes unitários, não precisa de banco |
|
||||
| `cargo test --test db_constraints` | 23 testes de constraint, **exigem Postgres** e `DATABASE_URL` |
|
||||
| `/usr/local/bin/container run -d --name mailpit -p 1025:1025 -p 8025:8025 docker.io/axllent/mailpit` | catcher de e-mail em dev (apple container; exige o serviço de pé: `container system start`); caixa em `http://localhost:8025`, API em `/api/v1/messages` |
|
||||
| `cargo run` | sobe em `:3000` e aplica as migrations |
|
||||
| `cargo sqlx prepare -- --all-targets` | regenera `.sqlx/` depois de mexer em SQL (precisa do banco de pé) |
|
||||
|
||||
+22
-2
@@ -83,12 +83,32 @@ URL assinada e enviada por e-mail que dá ao Client acesso a um Appointment espe
|
||||
cancelar. Não é login: não abre acesso a mais nada e não cria sessão.
|
||||
_Evite_: token, sessão, login sem senha, convite
|
||||
|
||||
### O profissional para fora
|
||||
|
||||
**Calendar feed**:
|
||||
A URL secreta e estável que publica os Appointments de um Professional no formato que o Apple
|
||||
Calendar e o Google Agenda assinam. É só leitura e é sempre atrasada: quem assina busca de tempos em
|
||||
tempos, então o feed é uma cópia da agenda, nunca a agenda. Quem tem a URL vê tudo que há nela, e a
|
||||
única revogação é trocá-la.
|
||||
_Evite_: exportação, sincronização, integração, iCal
|
||||
|
||||
### Estados de um Appointment
|
||||
|
||||
Só `confirmed` ocupa a agenda. Os outros quatro são histórico e liberam o horário.
|
||||
`pending` e `confirmed` ocupam a agenda. Os outros cinco são histórico e liberam o horário.
|
||||
|
||||
**pending**:
|
||||
O Client marcou e ainda não provou que o canal é dele. Segura o Slot enquanto o prazo corre, porque
|
||||
a corrida pelo horário se decide na escolha e não na confirmação.
|
||||
_Evite_: pré-reserva, provisório, aguardando
|
||||
|
||||
**confirmed**:
|
||||
O horário vale. É o estado em que todo Appointment nasce, inclusive os marcados pelo Client.
|
||||
O horário vale. É como nasce todo Appointment criado pelo painel; o do Client nasce `pending` e
|
||||
chega aqui quando ele confirma.
|
||||
|
||||
**expired**:
|
||||
O prazo do `pending` venceu e o Slot voltou para a grade. O horário ainda pode ser confirmado
|
||||
enquanto ninguém o tomar.
|
||||
_Evite_: cancelado, perdido, vencido
|
||||
|
||||
**cancelled_by_client** / **cancelled_by_company**:
|
||||
O horário foi desfeito, e por quem. São estados distintos porque respondem perguntas diferentes
|
||||
|
||||
+51
-5
@@ -18,10 +18,12 @@ POST /api/v1/public/appointment/confirm/{token} confirmar pelo e
|
||||
GET /api/v1/public/appointment/{token} ver (a página faz polling aqui)
|
||||
DELETE /api/v1/public/appointment/{token} cancelar
|
||||
POST /api/v1/webhooks/whatsapp a whatsapp-api entrega as mensagens
|
||||
GET /api/v1/public/calendar/{token}/feed.ics o Calendar feed do profissional
|
||||
```
|
||||
|
||||
Do lado autenticado: CRUD de empresa, profissional, cliente, serviço, agenda semanal, férias e
|
||||
agendamento. **306 testes**, sendo 17 contra Postgres de verdade.
|
||||
agendamento, mais o Calendar feed do profissional. **384 testes**, sendo 23 contra Postgres de
|
||||
verdade.
|
||||
|
||||
## Fases concluídas
|
||||
|
||||
@@ -97,6 +99,49 @@ Fora da v1, de propósito: reagendar (não existe no backend; o caminho é cance
|
||||
"sem preferência de profissional" (pede endpoint agregado de slots), logo/cor por empresa, e nome
|
||||
público do produto.
|
||||
|
||||
## Calendar feed: backend pronto (2026-07-30, ADR 0006)
|
||||
|
||||
O Professional assina a própria agenda no Apple Calendar ou no Google Agenda. Um feed por
|
||||
Professional, `confirmed` + `completed` + `no_show`, de 90 dias atrás até o futuro inteiro, com
|
||||
cliente, serviço, telefone, preço e endereço no evento. Time off não entra. O porquê de cada escolha
|
||||
está no ADR 0006.
|
||||
|
||||
```
|
||||
GET /api/v1/public/calendar/{token}/feed.ics o feed (público, sem JWT)
|
||||
GET /api/v1/professional/{id}/calendar_feed a URL, gerando o token sob demanda
|
||||
POST /api/v1/professional/{id}/calendar_feed/rotate troca o token (única revogação)
|
||||
POST /api/v1/professional/{id}/calendar_feed/email manda o link ao próprio Professional
|
||||
```
|
||||
|
||||
Peças: `professionals.calendar_token text UNIQUE` nullable (migration `20260730110000`),
|
||||
`confirmation_code::generate_calendar_token` (26 símbolos, ~129 bits), `ensure_/rotate_/
|
||||
get_by_calendar_token` no repositório de Professional, `appointment::get_calendar_feed` (join de
|
||||
Client e Service, janela e status vindos do chamador), e `src/icalendar.rs` puro, com dobra em 75
|
||||
octetos, escape de texto, `UID` estável e o `ETag` por FNV do corpo. As três rotas autenticadas
|
||||
passam por `require_manage_professional`, e o feed responde 404 tanto para token desconhecido quanto
|
||||
para Professional inativo.
|
||||
|
||||
Duas coisas saíram diferentes do que este roteiro previa, e o porquê:
|
||||
|
||||
- **A URL é `/calendar/{token}/feed.ics`, não `/calendar/{token}.ics`.** O matchit 0.8 (roteador do
|
||||
axum) não aceita sufixo depois de um parâmetro: "named parameters must be followed by a `/` or the
|
||||
end of the route. Dynamic suffixes are not currently supported". A forma antiga faz o `Router`
|
||||
entrar em pânico no boot, não dá erro de compilação.
|
||||
- **O índice `(professional_id, start)` já existia** desde a migration `20260728120500`
|
||||
(`appointments_professional_start_idx`), então a migration nova só adiciona a coluna.
|
||||
|
||||
E uma que o roteiro não previa: `DTSTAMP` é o `updated_at` do Appointment, nunca `now()`. Com a hora
|
||||
da requisição o corpo mudaria a cada busca e o `ETag` nunca casaria, ou seja, o 304 não existiria.
|
||||
|
||||
Frontend (`schdlr-web`), o que falta:
|
||||
|
||||
1. `admin-api.ts`: as três chamadas novas.
|
||||
2. Quinta aba "Calendário" em `painel.equipe.$id.tsx`, ao lado de Grade e Folgas.
|
||||
3. Botão Apple (`webcal://…`), botão Google (`calendar.google.com/calendar/r?cid=…`), copiar URL,
|
||||
mandar por e-mail, e trocar o link atrás de um `AlertDialog` dizendo que o anterior morre.
|
||||
4. Copy honesta por plataforma: iPhone é um toque, Google só pelo computador e atualiza em até um
|
||||
dia.
|
||||
|
||||
## Depois: colocar no ar
|
||||
|
||||
Dockerfile, banco gerenciado ou VPS, TLS, backup. Nada disso existe.
|
||||
@@ -129,9 +174,10 @@ Não são "nice to have": cada um destes quebra em produção.
|
||||
- `get_timestamp_from_now` usa `3660 * hours` (`src/utils.rs:20`), e a hora tem 3600 segundos: o
|
||||
token de "8h" dura 8h08 e o refresh de "5 dias" dura 5,08. Efeito irrelevante, uma linha.
|
||||
- `compute_slots` e `company_local_date` buscam a empresa cada uma por sua conta.
|
||||
- `docs/database.dbml` descreve o schema de 2023 e já causou uma migration quebrada.
|
||||
- ~~`docs/database.dbml` descreve o schema de 2023~~: reescrito contra a realidade em 2026-07-28 e
|
||||
mantido desde então (a armadilha do CLAUDE.md é atualizá-lo na MESMA mudança que mexe no schema).
|
||||
- Os testes Robot foram renomeados mas nunca rodaram nesta retomada, e não cobrem nada além da
|
||||
fase 1.
|
||||
- `clippy -- -D warnings` falha com 7 warnings (4 no binário, 3 só nos testes), e é o comando que o
|
||||
CI roda. Três são o mesmo padrão `is_some()` seguido de `unwrap()` no `appointment.rs`, que é
|
||||
`if let` disfarçado.
|
||||
- ~~`clippy -- -D warnings` falha com 7 warnings~~: em 2026-07-30 o comando exato do CI
|
||||
(`cargo clippy -- -D warnings`) e também `--all-targets` passam limpos. Alguém consertou e não
|
||||
atualizou esta linha.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# O calendário do Professional sai por assinatura, não por integração com o Google
|
||||
|
||||
O Professional leva a própria agenda para o Apple Calendar ou o Google Agenda assinando uma URL
|
||||
secreta que devolve iCalendar (RFC 5545): o **Calendar feed**. Sem OAuth, sem escrever na conta
|
||||
dele, sem reconciliar evento editado do outro lado. A agenda dele vira uma cópia somente-leitura que
|
||||
o próprio aplicativo busca de tempos em tempos.
|
||||
|
||||
O primeiro preço é a defasagem, e ela é assimétrica: o Apple busca de hora em hora e deixa escolher
|
||||
o intervalo; o Google busca a cada 12 a 24 horas, sem intervalo configurável e sem botão de forçar.
|
||||
Marcação feita hoje para amanhã cedo pode não aparecer no Google Agenda antes do atendimento.
|
||||
Aceitamos porque o aviso de marcação e de cancelamento já sai por e-mail
|
||||
(`notify::professional_booked` e `professional_cancelled`): o feed é a visão do dia, não o canal de
|
||||
alerta, e o painel continua sendo a verdade.
|
||||
|
||||
O segundo preço é o Android. A ajuda do Google é explícita: assinar calendário por URL só existe no
|
||||
navegador de computador, nunca no aplicativo, em nenhuma plataforma. O iPhone assina com um toque
|
||||
num link `webcal://`; quem usa Google precisa de um computador uma vez. A tela diz isso com todas as
|
||||
letras e oferece mandar o link para o e-mail do Professional, que é como ele abre no PC depois. O
|
||||
que resolveria o Android é escrever na conta dele via OAuth, e isso custa consentimento, refresh
|
||||
token, review do Google para escopo sensível e reconciliação de eventos editados fora daqui: fica
|
||||
para quando o piloto provar que a falta dói.
|
||||
|
||||
A credencial vai na URL porque o aplicativo de calendário busca sem cabeçalho nenhum. É um token
|
||||
opaco de ~128 bits em coluna própria de `professionals`, gerado sob demanda no primeiro pedido (o
|
||||
mesmo `UPDATE ... COALESCE` idempotente do `ensure_access_code`, ADR 0005) e trocável pelo painel.
|
||||
São mais bits que os ~59 do código de acesso porque esta URL fica guardada por anos num servidor de
|
||||
terceiro, e alongar não custa nada. Quem tem a URL lê a agenda com nome e telefone dos Clients:
|
||||
trocar o token é a única revogação que existe, e o link já entregue ao Google não volta.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A rota é pública e mora em `public_routes()`, herdando o rate limit por IP de lá. Responde 404
|
||||
para token desconhecido e para Professional inativo: quem saiu do salão para de receber agenda
|
||||
nova e o aplicativo mostra erro de atualização, em vez de esvaziar o calendário em silêncio.
|
||||
- O feed é o estado completo, não um diário de mudanças: o que sai dele é apagado do celular de quem
|
||||
assina. Por isso a janela vai de 90 dias atrás até o futuro inteiro, sem cortar no Horizon, que
|
||||
vale para o Client e não para o painel.
|
||||
- Entram `confirmed`, `completed` e `no_show`. Os dois últimos chegam depois da hora, e filtrar só
|
||||
`confirmed` apagaria do calendário justamente o dia que o Professional acabou de trabalhar.
|
||||
`pending` fica de fora: com TTL de 15 minutos contra busca de hora em hora, seria quase sempre
|
||||
fantasma de horário que já expirou.
|
||||
- Cancelar não precisa avisar ninguém: o Appointment some do feed e o calendário o apaga na próxima
|
||||
busca, sem `METHOD:CANCEL` e sem convite.
|
||||
- Os instantes vão em UTC e o `UID` é estável por Appointment, então remarcar move o evento em vez
|
||||
de criar outro.
|
||||
- Não existe caminho de volta: evento que o Professional criar ou editar no calendário dele não vira
|
||||
Appointment aqui, e o aplicativo marca a assinatura como somente-leitura.
|
||||
@@ -35,6 +35,7 @@ Table professionals {
|
||||
active boolean [not null, default: true]
|
||||
avatar varchar(50) [not null, default: '']
|
||||
password_changed_at timestamptz [not null, default: `now()`, note: 'token emitido antes disto não vale mais (bug 66)']
|
||||
calendar_token text [unique, note: 'credencial do Calendar feed (ADR 0006), ~129 bits; NULL até o primeiro pedido do painel. Trocar é a única revogação']
|
||||
}
|
||||
|
||||
// clients.avatar: mesmo formato do de profissional (/api/v1/avatars/..., vazio
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Credencial do Calendar feed (ver docs/adr/0006): a URL secreta que o
|
||||
-- Professional assina no Apple Calendar ou no Google Agenda. Vai na URL porque
|
||||
-- aplicativo de calendario busca sem cabecalho nenhum.
|
||||
--
|
||||
-- Nullable: nasce sem, e ganha o token no primeiro pedido do painel (o mesmo
|
||||
-- UPDATE ... COALESCE idempotente do access_code de appointments). UNIQUE
|
||||
-- porque a busca do feed e por ele, e o indice tambem serve o lookup.
|
||||
--
|
||||
-- ~128 bits (26 simbolos do alfabeto sem ambiguos), contra os ~59 do codigo de
|
||||
-- acesso: esta URL fica guardada por anos num servidor de terceiro.
|
||||
ALTER TABLE "professionals" ADD COLUMN "calendar_token" text UNIQUE;
|
||||
|
||||
-- A consulta do feed (professional_id + janela em "start") ja e servida pelo
|
||||
-- appointments_professional_start_idx da migration 20260728120500.
|
||||
@@ -106,6 +106,23 @@ authorization: Bearer {{authToken}}
|
||||
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}}
|
||||
@@ -174,6 +191,12 @@ content-type: application/json
|
||||
### 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
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
//! - o de ACESSO (12 simbolos, ~59 bits), que substitui o JWT nos links
|
||||
//! visiveis (`/a/{code}`) e abre E cancela o agendamento, por isso o dobro
|
||||
//! do tamanho. Com o rate limit publico na frente, tentativa e erro nao
|
||||
//! alcanca nenhum dos dois.
|
||||
//! alcanca nenhum dos dois;
|
||||
//! - o do CALENDAR FEED (26 simbolos, ~129 bits), que publica a agenda inteira
|
||||
//! de um Professional e fica guardado por anos num servidor de terceiro
|
||||
//! (ADR 0006). Alongar nao custa nada, e aqui ninguem digita.
|
||||
|
||||
use rand_core::RngCore;
|
||||
|
||||
@@ -16,6 +19,9 @@ const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
||||
const PREFIX: &str = "AG-";
|
||||
const LEN: usize = 6;
|
||||
const ACCESS_LEN: usize = 12;
|
||||
/// 31^26 e ~2^129: os "128 bits" do ADR 0006 arredondados para cima pelo
|
||||
/// tamanho inteiro seguinte.
|
||||
const CALENDAR_LEN: usize = 26;
|
||||
|
||||
fn random_chars(len: usize) -> String {
|
||||
let mut rng = rand_core::OsRng;
|
||||
@@ -41,6 +47,13 @@ pub fn generate_access() -> String {
|
||||
random_chars(ACCESS_LEN)
|
||||
}
|
||||
|
||||
/// O token do Calendar feed: mesmo alfabeto, tamanho de credencial de longa
|
||||
/// duracao. Vive dentro de uma URL que um servidor de terceiro guarda, entao
|
||||
/// nunca precisa ser lido nem redigitado por gente.
|
||||
pub fn generate_calendar_token() -> String {
|
||||
random_chars(CALENDAR_LEN)
|
||||
}
|
||||
|
||||
/// Acha um codigo em qualquer lugar do texto, em qualquer caixa. A mensagem
|
||||
/// pre-preenchida do wa.me e editavel, e so o codigo precisa sobreviver.
|
||||
pub fn extract(text: &str) -> Option<String> {
|
||||
@@ -81,6 +94,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_tokens_are_long_enough_to_live_on_someone_elses_server() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..50 {
|
||||
let token = generate_calendar_token();
|
||||
assert_eq!(token.len(), 26, "{token}");
|
||||
assert!(token.as_bytes().iter().all(|b| ALPHABET.contains(b)), "{token}");
|
||||
// Vai cru numa URL: nada que precise de escape.
|
||||
assert!(token.chars().all(|c| c.is_ascii_alphanumeric()), "{token}");
|
||||
assert!(seen.insert(token), "dois tokens iguais em 50 sorteios");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_from_the_middle_of_an_edited_message() {
|
||||
assert_eq!(extract("oi, quero confirmar!! codigo AG-7KM3QF obrigado").as_deref(), Some("AG-7KM3QF"));
|
||||
|
||||
@@ -8,8 +8,10 @@ use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
authz::Role,
|
||||
confirmation_code,
|
||||
error::{AppError, DataAccessError},
|
||||
models::professional::{Claims, Professional, UpdateProfessional},
|
||||
notify,
|
||||
utils::{hash_password, normalize_phone},
|
||||
AppState,
|
||||
};
|
||||
@@ -234,6 +236,122 @@ pub async fn create_professional(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Calendar feed (ADR 0006) ---------------------------------------------
|
||||
//
|
||||
// Tres rotas para a MESMA credencial: ver o link, trocar o link, e mandar o link
|
||||
// por e-mail para quem vai assinar. Todas exigem gerenciar aquele profissional,
|
||||
// entao o dono da agenda e o admin da empresa dele passam, e mais ninguem.
|
||||
|
||||
/// A URL que o aplicativo de calendario busca. Aponta para a API e nao para uma
|
||||
/// tela, ao contrario dos links de e-mail do Client, mas sai da MESMA
|
||||
/// `public_base_url`: a API mora sob `/api/v1` do mesmo host, que e o que o logo
|
||||
/// dos e-mails ja assume (`notify::logo_url`).
|
||||
fn feed_url(base: &str, token: &str) -> String {
|
||||
format!("{}/api/v1/public/calendar/{token}/feed.ics", base.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
/// A mesma URL em `webcal://`: e o esquema que o iPhone entrega ao aplicativo de
|
||||
/// calendario com um toque. Em `https://` o mesmo link abre no navegador e baixa
|
||||
/// um arquivo, que nao assina nada.
|
||||
fn webcal_url(url: &str) -> String {
|
||||
match url.split_once("://") {
|
||||
Some((_, rest)) => format!("webcal://{rest}"),
|
||||
// Base configurada sem esquema: melhor devolver o que se tem do que
|
||||
// montar um `webcal://` a partir de nada.
|
||||
None => url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_json(base: &str, token: &str) -> Value {
|
||||
let url = feed_url(base, token);
|
||||
json!({ "webcal_url": webcal_url(&url), "url": url })
|
||||
}
|
||||
|
||||
/// O alvo das rotas de Calendar feed, ja autorizado.
|
||||
///
|
||||
/// A empresa vem da LINHA do profissional, nunca do token de quem chama: e o que
|
||||
/// impede um admin de operar a agenda de outra empresa.
|
||||
async fn manageable_professional(claims: &Claims, app: &Arc<AppState>, id: i32) -> Result<Professional, AppError> {
|
||||
let professional = match app.professional_repo.get_professional_by_id(id).await {
|
||||
Ok(professional) => professional,
|
||||
Err(DataAccessError::NotFound) => return Err(AppError::ProfessionalDoesNotExist),
|
||||
Err(_) => return Err(AppError::InternalServerError),
|
||||
};
|
||||
claims.require_manage_professional(id, professional.company_id)?;
|
||||
Ok(professional)
|
||||
}
|
||||
|
||||
/// O token de quem ja tem, ou um novo, gerado sob demanda no primeiro pedido.
|
||||
///
|
||||
/// A linha ja veio da autorizacao, entao quem ja tem token nao paga um UPDATE por
|
||||
/// abertura de tela. Quem nao tem cai no `ensure`, e la o COALESCE do banco e que
|
||||
/// arbitra dois primeiros pedidos ao mesmo tempo: os dois saem com o token que
|
||||
/// ficou gravado, nunca um com o do outro.
|
||||
async fn calendar_token_of(app: &Arc<AppState>, professional: &Professional) -> Result<String, AppError> {
|
||||
match &professional.calendar_token {
|
||||
Some(token) => Ok(token.clone()),
|
||||
None => app
|
||||
.professional_repo
|
||||
.ensure_calendar_token(professional.id, confirmation_code::generate_calendar_token())
|
||||
.await
|
||||
.map_err(|e| e.not_found_as(AppError::ProfessionalDoesNotExist)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A URL do feed, gerando o token no primeiro pedido.
|
||||
pub async fn get_calendar_feed(claims: Claims, State(app): State<Arc<AppState>>, Path(id): Path<i32>) -> Result<Json<Value>, AppError> {
|
||||
let professional = manageable_professional(&claims, &app, id).await?;
|
||||
let token = calendar_token_of(&app, &professional).await?;
|
||||
|
||||
Ok(Json(feed_json(&app.config.public_base_url, &token)))
|
||||
}
|
||||
|
||||
/// Troca o token. E a unica revogacao que existe: a URL antiga para de
|
||||
/// responder, e o link ja entregue ao Google nao volta.
|
||||
pub async fn rotate_calendar_feed(claims: Claims, State(app): State<Arc<AppState>>, Path(id): Path<i32>) -> Result<Json<Value>, AppError> {
|
||||
manageable_professional(&claims, &app, id).await?;
|
||||
|
||||
let token = app
|
||||
.professional_repo
|
||||
.rotate_calendar_token(id, confirmation_code::generate_calendar_token())
|
||||
.await
|
||||
.map_err(|e| e.not_found_as(AppError::ProfessionalDoesNotExist))?;
|
||||
|
||||
Ok(Json(feed_json(&app.config.public_base_url, &token)))
|
||||
}
|
||||
|
||||
/// Manda o link para o e-mail do proprio Professional, que e como ele o abre no
|
||||
/// computador depois: assinar calendario por URL no Google so existe la.
|
||||
pub async fn email_calendar_feed(claims: Claims, State(app): State<Arc<AppState>>, Path(id): Path<i32>) -> Result<Json<Value>, AppError> {
|
||||
let professional = manageable_professional(&claims, &app, id).await?;
|
||||
|
||||
// Antes de gerar token nenhum: sem endereco, este pedido nao tem como dar
|
||||
// certo, e 400 diz isso melhor que um "ok" que nao enviou nada.
|
||||
if professional.email.is_empty() {
|
||||
return Err(AppError::BadRequest("professional has no email address".to_string()));
|
||||
}
|
||||
|
||||
let company = app
|
||||
.company_repo
|
||||
.get_company_by_id(professional.company_id)
|
||||
.await
|
||||
.map_err(|e| e.not_found_as(AppError::CompanyDoesNotExist))?;
|
||||
|
||||
let token = calendar_token_of(&app, &professional).await?;
|
||||
let url = feed_url(&app.config.public_base_url, &token);
|
||||
let email = notify::calendar_feed(&company, &professional, &url, &webcal_url(&url));
|
||||
|
||||
// Envio esperado, ao contrario do agendamento publico (bug 44): quem clicou
|
||||
// "mandar por e-mail" no painel esta olhando a tela, e relay fora do ar tem
|
||||
// que virar erro visivel em vez de um "ok" que mente.
|
||||
app.mailer.send(email).await.map_err(|err| {
|
||||
tracing::error!("falha ao enviar o link do calendar feed para {}: {err}", professional.email);
|
||||
AppError::InternalServerError
|
||||
})?;
|
||||
|
||||
Ok(Json(json!({"status": "ok"})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "professional_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -2520,3 +2520,262 @@ async fn create_professional_without_company_id_is_a_bad_request() {
|
||||
let resp_json = response_json(response).await;
|
||||
assert_eq!(resp_json["error"], "company_id is required");
|
||||
}
|
||||
|
||||
// --- Calendar feed (ADR 0006) ---------------------------------------------
|
||||
|
||||
/// Um token de feed qualquer, no formato que o gerador produz.
|
||||
const FEED_TOKEN: &str = "MJH7QKX3PT9RBVCDFGNWZ2456A";
|
||||
/// O que `Config::empty()` usa como base publica.
|
||||
const FEED_URL: &str = "http://localhost:5173/api/v1/public/calendar/MJH7QKX3PT9RBVCDFGNWZ2456A/feed.ics";
|
||||
const FEED_WEBCAL: &str = "webcal://localhost:5173/api/v1/public/calendar/MJH7QKX3PT9RBVCDFGNWZ2456A/feed.ics";
|
||||
|
||||
/// So os repositorios que as rotas de feed tocam. Os outros ficam vazios de
|
||||
/// proposito: chamada inesperada derruba o teste.
|
||||
fn calendar_app(professional: MockProfessionalRepository, company: MockCompanyRepository, mailer: MockMailer) -> axum::Router {
|
||||
let shared_state = Arc::new(AppState {
|
||||
service_repo: Arc::new(MockServiceRepository::new()) as DynServiceRepository,
|
||||
availability_repo: Arc::new(MockAvailabilityRepository::new()) as DynAvailabilityRepository,
|
||||
company_repo: Arc::new(company) as DynCompanyRepository,
|
||||
professional_repo: Arc::new(professional) as DynProfessionalRepository,
|
||||
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
|
||||
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
|
||||
mailer: Arc::new(mailer) as DynMailer,
|
||||
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
|
||||
config: Config::empty(),
|
||||
});
|
||||
routes().with_state(shared_state)
|
||||
}
|
||||
|
||||
/// O dono da agenda: profissional comum, id 7 da empresa 1.
|
||||
fn feed_target(token: Option<&str>) -> Professional {
|
||||
let mut target = generate_professional();
|
||||
target.id = 7;
|
||||
target.email = "profissional@possebom.com".to_string();
|
||||
target.calendar_token = token.map(String::from);
|
||||
target
|
||||
}
|
||||
|
||||
/// Quem chama, com o JWT e a sessao de acordo: o extractor le papel e empresa do
|
||||
/// BANCO, entao um mock que discorde do token autoriza com outro papel.
|
||||
fn caller(id: i32, role: &str, company_id: i32) -> (serde_json::Value, crate::models::professional::SessionState) {
|
||||
let mut caller = generate_professional();
|
||||
caller.id = id;
|
||||
caller.role = role.to_string();
|
||||
caller.company_id = company_id;
|
||||
let jwt = caller.to_jwt().expect("emitir o jwt do teste");
|
||||
(jwt.0, session_as(role, company_id))
|
||||
}
|
||||
|
||||
fn company_fixture() -> crate::models::company::Company {
|
||||
crate::models::company::Company {
|
||||
id: 1,
|
||||
name: "Possebom".to_string(),
|
||||
slug: "possebom".to_string(),
|
||||
timezone: "America/Sao_Paulo".to_string(),
|
||||
active: true,
|
||||
min_lead_minutes: 60,
|
||||
max_horizon_days: 60,
|
||||
reminder_hours: 24,
|
||||
logo: String::new(),
|
||||
address: String::new(),
|
||||
booking_flow: "service_first".to_string(),
|
||||
created_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Primeiro pedido do dono da agenda: nao ha token, e ele nasce aqui.
|
||||
#[tokio::test]
|
||||
async fn the_owner_of_the_agenda_gets_the_feed_url_created_on_demand() {
|
||||
let mut repo = MockProfessionalRepository::new();
|
||||
let (jwt, session) = caller(7, "professional", 1);
|
||||
repo.expect_update_last_activity().returning(move |_| Ok(session.clone()));
|
||||
repo.expect_get_professional_by_id().with(eq(7)).returning(|_| Ok(feed_target(None)));
|
||||
// O candidato e sorteado pelo handler; o que importa e que o UPDATE aconteca
|
||||
// para o id certo.
|
||||
repo.expect_ensure_calendar_token()
|
||||
.withf(|id, candidate| *id == 7 && candidate.len() == 26)
|
||||
.returning(|_, _| Ok(FEED_TOKEN.to_string()));
|
||||
|
||||
let app = calendar_app(repo, MockCompanyRepository::new(), MockMailer::new());
|
||||
let response = app
|
||||
.oneshot(Request::get("/professional/7/calendar_feed").jwt_empty_body(Json(jwt)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["url"], FEED_URL);
|
||||
// O webcal:// e o que o iPhone entrega ao calendario com um toque.
|
||||
assert_eq!(body["webcal_url"], FEED_WEBCAL);
|
||||
}
|
||||
|
||||
/// Quem ja tem token nao paga um UPDATE por abertura de tela: o mock sem
|
||||
/// `expect_ensure_calendar_token` derruba o teste se o handler escrever.
|
||||
#[tokio::test]
|
||||
async fn an_existing_token_is_reused_without_writing() {
|
||||
let mut repo = MockProfessionalRepository::new();
|
||||
let (jwt, session) = caller(7, "professional", 1);
|
||||
repo.expect_update_last_activity().returning(move |_| Ok(session.clone()));
|
||||
repo.expect_get_professional_by_id()
|
||||
.with(eq(7))
|
||||
.returning(|_| Ok(feed_target(Some(FEED_TOKEN))));
|
||||
|
||||
let app = calendar_app(repo, MockCompanyRepository::new(), MockMailer::new());
|
||||
let response = app
|
||||
.oneshot(Request::get("/professional/7/calendar_feed").jwt_empty_body(Json(jwt)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response_json(response).await["url"], FEED_URL);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_admin_of_the_company_also_manages_the_feed_of_its_professional() {
|
||||
let mut repo = MockProfessionalRepository::new();
|
||||
let (jwt, session) = caller(2, "admin", 1);
|
||||
repo.expect_update_last_activity().returning(move |_| Ok(session.clone()));
|
||||
repo.expect_get_professional_by_id()
|
||||
.with(eq(7))
|
||||
.returning(|_| Ok(feed_target(Some(FEED_TOKEN))));
|
||||
|
||||
let app = calendar_app(repo, MockCompanyRepository::new(), MockMailer::new());
|
||||
let response = app
|
||||
.oneshot(Request::get("/professional/7/calendar_feed").jwt_empty_body(Json(jwt)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
/// Quem tem a URL le a agenda inteira, com nome e telefone dos clientes: colega
|
||||
/// da mesma empresa nao alcanca nenhuma das tres rotas, e admin de OUTRA empresa
|
||||
/// tambem nao. Sem `expect_ensure_calendar_token`, `expect_rotate_calendar_token`
|
||||
/// e sem envio: nenhum caminho negado pode escrever nem mandar e-mail.
|
||||
#[tokio::test]
|
||||
async fn a_colleague_and_an_outsider_admin_reach_none_of_the_three_routes() {
|
||||
for (id, role, company_id) in [(5, "professional", 1), (2, "admin", 2)] {
|
||||
let mut repo = MockProfessionalRepository::new();
|
||||
let (jwt, session) = caller(id, role, company_id);
|
||||
repo.expect_update_last_activity().returning(move |_| Ok(session.clone()));
|
||||
repo.expect_get_professional_by_id()
|
||||
.with(eq(7))
|
||||
.returning(|_| Ok(feed_target(Some(FEED_TOKEN))));
|
||||
|
||||
let app = calendar_app(repo, MockCompanyRepository::new(), MockMailer::new());
|
||||
for request in [
|
||||
Request::get("/professional/7/calendar_feed").jwt_empty_body(Json(jwt.clone())),
|
||||
Request::post("/professional/7/calendar_feed/rotate").jwt_empty_body(Json(jwt.clone())),
|
||||
Request::post("/professional/7/calendar_feed/email").jwt_empty_body(Json(jwt.clone())),
|
||||
] {
|
||||
let uri = request.uri().to_string();
|
||||
let response = app.clone().oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN, "{role} {id} da empresa {company_id} em {uri}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trocar o token e a unica revogacao que existe. O `rotate` nunca cai no
|
||||
/// `ensure`: sem expectativa para ele, um COALESCE aqui derrubaria o teste (e
|
||||
/// devolveria o token ANTIGO, que e o bug que isso previne).
|
||||
#[tokio::test]
|
||||
async fn rotating_replaces_the_token_even_when_one_already_exists() {
|
||||
let mut repo = MockProfessionalRepository::new();
|
||||
let (jwt, session) = caller(7, "professional", 1);
|
||||
repo.expect_update_last_activity().returning(move |_| Ok(session.clone()));
|
||||
repo.expect_get_professional_by_id()
|
||||
.with(eq(7))
|
||||
.returning(|_| Ok(feed_target(Some("TOKENVELHOQUEPRECISASAIR"))));
|
||||
repo.expect_rotate_calendar_token()
|
||||
.withf(|id, token| *id == 7 && token.len() == 26)
|
||||
.returning(|_, _| Ok(FEED_TOKEN.to_string()));
|
||||
|
||||
let app = calendar_app(repo, MockCompanyRepository::new(), MockMailer::new());
|
||||
let response = app
|
||||
.oneshot(Request::post("/professional/7/calendar_feed/rotate").jwt_empty_body(Json(jwt)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["url"], FEED_URL);
|
||||
assert!(!body["url"].as_str().unwrap().contains("TOKENVELHO"), "a resposta veio com o token antigo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emailing_the_link_sends_both_urls_to_the_professional() {
|
||||
let mut repo = MockProfessionalRepository::new();
|
||||
let (jwt, session) = caller(7, "professional", 1);
|
||||
repo.expect_update_last_activity().returning(move |_| Ok(session.clone()));
|
||||
repo.expect_get_professional_by_id()
|
||||
.with(eq(7))
|
||||
.returning(|_| Ok(feed_target(Some(FEED_TOKEN))));
|
||||
let mut company = MockCompanyRepository::new();
|
||||
company.expect_get_company_by_id().with(eq(1)).returning(|_| Ok(company_fixture()));
|
||||
let mut mailer = MockMailer::new();
|
||||
mailer
|
||||
.expect_send()
|
||||
.withf(|email| {
|
||||
assert_eq!(email.to, "profissional@possebom.com", "o link vai para o proprio profissional");
|
||||
assert!(email.body.contains(FEED_URL), "sem a URL do Google: {}", email.body);
|
||||
assert!(email.body.contains(FEED_WEBCAL), "sem o webcal do iPhone: {}", email.body);
|
||||
true
|
||||
})
|
||||
.returning(|_| Ok(()));
|
||||
|
||||
let app = calendar_app(repo, company, mailer);
|
||||
let response = app
|
||||
.oneshot(Request::post("/professional/7/calendar_feed/email").jwt_empty_body(Json(jwt)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response_json(response).await["status"], "ok");
|
||||
}
|
||||
|
||||
/// Sem endereco nao ha como atender o pedido: 400 antes de gerar token ou tocar
|
||||
/// no mailer (nenhum dos dois tem expectativa aqui).
|
||||
#[tokio::test]
|
||||
async fn emailing_the_link_of_someone_without_an_email_is_a_bad_request() {
|
||||
let mut repo = MockProfessionalRepository::new();
|
||||
let (jwt, session) = caller(7, "professional", 1);
|
||||
repo.expect_update_last_activity().returning(move |_| Ok(session.clone()));
|
||||
repo.expect_get_professional_by_id().with(eq(7)).returning(|_| {
|
||||
let mut target = feed_target(Some(FEED_TOKEN));
|
||||
target.email = String::new();
|
||||
Ok(target)
|
||||
});
|
||||
|
||||
let app = calendar_app(repo, MockCompanyRepository::new(), MockMailer::new());
|
||||
let response = app
|
||||
.oneshot(Request::post("/professional/7/calendar_feed/email").jwt_empty_body(Json(jwt)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(response_json(response).await["error"], "professional has no email address");
|
||||
}
|
||||
|
||||
/// Relay fora do ar vira erro na tela, nao um "ok" que nao enviou nada: quem
|
||||
/// clicou esta esperando a resposta (o contrario do agendamento publico, bug 44).
|
||||
#[tokio::test]
|
||||
async fn a_dead_relay_becomes_a_visible_error_instead_of_a_silent_ok() {
|
||||
let mut repo = MockProfessionalRepository::new();
|
||||
let (jwt, session) = caller(7, "professional", 1);
|
||||
repo.expect_update_last_activity().returning(move |_| Ok(session.clone()));
|
||||
repo.expect_get_professional_by_id()
|
||||
.with(eq(7))
|
||||
.returning(|_| Ok(feed_target(Some(FEED_TOKEN))));
|
||||
let mut company = MockCompanyRepository::new();
|
||||
company.expect_get_company_by_id().with(eq(1)).returning(|_| Ok(company_fixture()));
|
||||
let mut mailer = MockMailer::new();
|
||||
mailer.expect_send().returning(|_| Err(anyhow::anyhow!("connection refused")));
|
||||
|
||||
let app = calendar_app(repo, company, mailer);
|
||||
let response = app
|
||||
.oneshot(Request::post("/professional/7/calendar_feed/email").jwt_empty_body(Json(jwt)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
+212
-1
@@ -9,6 +9,8 @@ use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use chrono::NaiveDate;
|
||||
@@ -20,7 +22,7 @@ use crate::{
|
||||
confirmation_code,
|
||||
controllers::availability::{company_local_date, compute_day_availability, compute_slots},
|
||||
error::{AppError, DataAccessError},
|
||||
magic_link,
|
||||
icalendar, magic_link,
|
||||
models::{
|
||||
appointment::{Appointment, AppointmentStatus, NewAppointment},
|
||||
client::Client,
|
||||
@@ -835,6 +837,80 @@ pub async fn cancel_appointment(State(app): State<Arc<AppState>>, Path(token): P
|
||||
Ok(Json(json!({"status": "ok"})))
|
||||
}
|
||||
|
||||
/// Quanto tempo para tras o Calendar feed publica.
|
||||
///
|
||||
/// O feed e o estado completo, nao um diario de mudancas: o que sai dele o
|
||||
/// celular apaga. Por isso a janela nao usa o Horizon da empresa, que existe
|
||||
/// para o Client, e por isso ela olha para tras: sem os 90 dias o calendario do
|
||||
/// Professional esvaziaria o mes passado (ADR 0006).
|
||||
const FEED_PAST_DAYS: i64 = 90;
|
||||
|
||||
/// O Calendar feed de um Professional: a agenda dele em iCalendar, buscada de
|
||||
/// hora em hora pelo aplicativo de calendario que assinou a URL.
|
||||
///
|
||||
/// Publico como o resto do arquivo, e sem `Claims`: quem autentica e o token da
|
||||
/// propria URL, porque aplicativo de calendario nao manda cabecalho nenhum. O
|
||||
/// rate limit por IP de `public_routes` vale aqui igual.
|
||||
pub async fn calendar_feed(State(app): State<Arc<AppState>>, Path(token): Path<String>, headers: HeaderMap) -> Result<Response, AppError> {
|
||||
let professional = match app.professional_repo.get_by_calendar_token(token).await {
|
||||
Ok(professional) => professional,
|
||||
Err(DataAccessError::NotFound) => return Err(AppError::NotFound),
|
||||
Err(_) => return Err(AppError::InternalServerError),
|
||||
};
|
||||
|
||||
// Quem saiu do salao para de receber agenda nova, e o MESMO 404 do token
|
||||
// desconhecido: o aplicativo mostra erro de atualizacao em vez de esvaziar o
|
||||
// calendario em silencio, e de fora nao da para distinguir token errado de
|
||||
// profissional desativado.
|
||||
if !professional.active {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
let company = app
|
||||
.company_repo
|
||||
.get_company_by_id(professional.company_id)
|
||||
.await
|
||||
.map_err(|e| e.not_found_as(AppError::NotFound))?;
|
||||
|
||||
let from = chrono::Utc::now() - chrono::Duration::days(FEED_PAST_DAYS);
|
||||
let appointments = app
|
||||
.appointment_repo
|
||||
.get_calendar_feed(professional.id, from, AppointmentStatus::labels(&AppointmentStatus::CALENDAR_FEED))
|
||||
.await
|
||||
.map_err(|_| AppError::InternalServerError)?;
|
||||
|
||||
let body = icalendar::feed(&company, &appointments);
|
||||
let etag = icalendar::etag(&body);
|
||||
|
||||
// O Apple busca de hora em hora, por assinante: sem o 304 cada aparelho
|
||||
// baixaria a agenda inteira 24 vezes por dia para nada.
|
||||
if if_none_match(&headers).any(|known| known == etag) {
|
||||
return Ok((StatusCode::NOT_MODIFIED, [(header::ETAG, etag)]).into_response());
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/calendar; charset=utf-8".to_string()),
|
||||
(header::ETAG, etag),
|
||||
// A URL e credencial: nada de cache compartilhado no caminho.
|
||||
(header::CACHE_CONTROL, "private, max-age=0".to_string()),
|
||||
],
|
||||
body,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Os ETags que quem chama diz ja ter. Pode vir mais de um na mesma linha.
|
||||
fn if_none_match(headers: &HeaderMap) -> impl Iterator<Item = &str> {
|
||||
headers
|
||||
.get(header::IF_NONE_MATCH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
@@ -2104,4 +2180,139 @@ mod tests {
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["error"], "this appointment is not active");
|
||||
}
|
||||
|
||||
// -- Calendar feed (ADR 0006) -----------------------------------------
|
||||
|
||||
/// O token do profissional 7 nos testes de feed.
|
||||
const FEED_TOKEN: &str = "MJH7QKX3PT9RBVCDFGNWZ2456A";
|
||||
|
||||
fn feed_uri(token: &str) -> String {
|
||||
format!("/calendar/{token}/feed.ics")
|
||||
}
|
||||
|
||||
fn professional_with_feed(active: bool) -> Professional {
|
||||
let mut p = professional7();
|
||||
p.active = active;
|
||||
p.calendar_token = Some(FEED_TOKEN.to_string());
|
||||
p
|
||||
}
|
||||
|
||||
fn feed_row() -> crate::models::appointment::CalendarAppointment {
|
||||
crate::models::appointment::CalendarAppointment {
|
||||
id: 99,
|
||||
start: at_utc("2030-09-02T14:00:00Z"),
|
||||
end: at_utc("2030-09-02T14:30:00Z"),
|
||||
updated_at: at_utc("2030-08-20T10:00:00Z"),
|
||||
status: "confirmed".to_string(),
|
||||
description: None,
|
||||
price_cents: Some(5000),
|
||||
client_name: "Maria".to_string(),
|
||||
client_phone: "41999998888".to_string(),
|
||||
service_name: Some("Corte".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn at_utc(rfc3339: &str) -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339(rfc3339).expect("instante valido").with_timezone(&Utc)
|
||||
}
|
||||
|
||||
async fn response_text(response: axum::response::Response) -> String {
|
||||
let bytes = http_body_util::BodyExt::collect(response.into_body()).await.unwrap().to_bytes();
|
||||
String::from_utf8(bytes.to_vec()).expect("o corpo do feed e UTF-8")
|
||||
}
|
||||
|
||||
/// Os mocks do caminho completo do feed, com a checagem do que o handler PEDE
|
||||
/// ao repositorio: os tres status do ADR e a janela de 90 dias para tras.
|
||||
fn feed_mocks(professional: Professional) -> Mocks {
|
||||
let mut m = Mocks::default();
|
||||
m.professional
|
||||
.expect_get_by_calendar_token()
|
||||
.with(eq(FEED_TOKEN.to_string()))
|
||||
.returning(move |_| Ok(professional.clone()));
|
||||
m.company.expect_get_company_by_id().with(eq(1)).returning(|_| Ok(company_fixture()));
|
||||
m.appointment
|
||||
.expect_get_calendar_feed()
|
||||
.withf(|id, from, statuses| {
|
||||
// `pending`, `expired` e os dois cancelados ficam fora: pending
|
||||
// seria fantasma de horario expirado, e o que saiu do feed o
|
||||
// celular apaga.
|
||||
assert_eq!(
|
||||
statuses,
|
||||
&vec!["confirmed".to_string(), "completed".to_string(), "no_show".to_string()],
|
||||
"status pedidos"
|
||||
);
|
||||
// Sem data cravada: o corte e relativo a agora, e o teste so
|
||||
// confere a distancia.
|
||||
let expected = Utc::now() - chrono::Duration::days(90);
|
||||
let drift = (*from - expected).num_seconds().abs();
|
||||
assert!(drift < 60, "a janela pedida foi {from}, esperava ~{expected}");
|
||||
*id == 7
|
||||
})
|
||||
.returning(|_, _, _| Ok(vec![feed_row()]));
|
||||
m
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_known_token_gets_the_agenda_as_icalendar() {
|
||||
let response = feed_mocks(professional_with_feed(true))
|
||||
.into_app()
|
||||
.oneshot(public_get(&feed_uri(FEED_TOKEN)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers().get(header::CONTENT_TYPE).unwrap(), "text/calendar; charset=utf-8");
|
||||
assert!(response.headers().get(header::ETAG).is_some(), "sem ETag nao ha 304");
|
||||
let body = response_text(response).await;
|
||||
assert!(body.starts_with("BEGIN:VCALENDAR\r\n"), "{body}");
|
||||
assert!(body.contains("SUMMARY:Maria - Corte\r\n"), "{body}");
|
||||
assert!(body.contains("UID:appointment-99@"), "{body}");
|
||||
}
|
||||
|
||||
/// Token desconhecido e 404, e nada mais e consultado: os outros mocks estao
|
||||
/// sem expectativa nenhuma.
|
||||
#[tokio::test]
|
||||
async fn an_unknown_token_is_a_404() {
|
||||
let mut m = Mocks::default();
|
||||
m.professional.expect_get_by_calendar_token().returning(|_| Err(DataAccessError::NotFound));
|
||||
|
||||
let response = m.into_app().oneshot(public_get(&feed_uri("NAOEXISTE"))).await.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// Quem saiu do salao para de receber agenda nova, com o MESMO 404 do token
|
||||
/// errado. O mock de appointment sem expectativa prova que a agenda nao chega
|
||||
/// a ser lida.
|
||||
#[tokio::test]
|
||||
async fn a_deactivated_professional_gets_the_same_404_and_the_agenda_is_never_read() {
|
||||
let mut m = Mocks::default();
|
||||
let inactive = professional_with_feed(false);
|
||||
m.professional.expect_get_by_calendar_token().returning(move |_| Ok(inactive.clone()));
|
||||
|
||||
let response = m.into_app().oneshot(public_get(&feed_uri(FEED_TOKEN))).await.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// O Apple busca de hora em hora, por assinante: a segunda visita com o mesmo
|
||||
/// ETag tem que sair 304 e sem corpo.
|
||||
#[tokio::test]
|
||||
async fn the_same_agenda_answers_304_on_the_next_visit() {
|
||||
let app = feed_mocks(professional_with_feed(true)).into_app();
|
||||
|
||||
let first = app.clone().oneshot(public_get(&feed_uri(FEED_TOKEN))).await.unwrap();
|
||||
let etag = first.headers().get(header::ETAG).unwrap().to_str().unwrap().to_string();
|
||||
|
||||
let conditional = Request::get(feed_uri(FEED_TOKEN))
|
||||
.header("x-forwarded-for", TEST_IP)
|
||||
.header(header::IF_NONE_MATCH, &etag)
|
||||
.body(Body::empty())
|
||||
.expect("request valido");
|
||||
let second = app.oneshot(conditional).await.unwrap();
|
||||
|
||||
assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
|
||||
assert_eq!(second.headers().get(header::ETAG).unwrap(), etag.as_str());
|
||||
assert!(response_text(second).await.is_empty(), "304 nao carrega corpo");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
//! O Calendar feed em iCalendar (RFC 5545): o texto que o Apple Calendar e o
|
||||
//! Google Agenda buscam da URL assinada (ADR 0006).
|
||||
//!
|
||||
//! Puro de proposito, como o `availability.rs`: recebe a empresa e as linhas do
|
||||
//! feed e devolve o corpo inteiro, sem banco e sem HTTP. As duas coisas que
|
||||
//! erram facil aqui, a dobra em 75 octetos e o escape de texto, ficam provaveis
|
||||
//! sem subir nada.
|
||||
//!
|
||||
//! NAO copie do `ics.ts` do frontend: la o arquivo tem um evento montado no
|
||||
//! navegador e nenhuma linha passa de 75 octetos por acidente. Aqui o evento
|
||||
//! carrega nome, telefone, preco e endereco, e a dobra e obrigatoria.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::{
|
||||
models::{
|
||||
appointment::{AppointmentStatus, CalendarAppointment},
|
||||
company::Company,
|
||||
},
|
||||
utils::money,
|
||||
};
|
||||
|
||||
const PRODID: &str = "-//Vem Marcar//Calendar Feed//PT";
|
||||
/// O `UID` precisa de forma de endereco de e-mail e ser estavel para sempre:
|
||||
/// remarcar move o evento em vez de criar outro (ADR 0006).
|
||||
const UID_DOMAIN: &str = "vemmarcar.com";
|
||||
/// O teto de octetos de uma linha, do RFC 5545 3.1.
|
||||
const FOLD_LIMIT: usize = 75;
|
||||
/// O que pedimos a quem assina: de hora em hora. O Apple obedece e deixa o
|
||||
/// dono mudar; o Google ignora e busca de 12 a 24 horas.
|
||||
const REFRESH_INTERVAL: &str = "PT1H";
|
||||
|
||||
/// O calendario inteiro de um Professional. O feed e o ESTADO COMPLETO, nao um
|
||||
/// diario de mudancas: o que nao esta aqui o celular apaga na proxima busca, e e
|
||||
/// assim que cancelar chega ao calendario sem `METHOD:CANCEL` nenhum.
|
||||
pub fn feed(company: &Company, appointments: &[CalendarAppointment]) -> String {
|
||||
let mut out = String::new();
|
||||
line(&mut out, "BEGIN:VCALENDAR");
|
||||
line(&mut out, "VERSION:2.0");
|
||||
line(&mut out, &format!("PRODID:{PRODID}"));
|
||||
line(&mut out, "CALSCALE:GREGORIAN");
|
||||
line(&mut out, "METHOD:PUBLISH");
|
||||
// O nome que aparece na lista de calendarios do celular.
|
||||
line(&mut out, &format!("X-WR-CALNAME:{}", escape(&company.name)));
|
||||
// Os instantes vao em UTC; isto diz em que fuso a agenda faz sentido,
|
||||
// para quem viaja nao ver o dia deslocado no titulo.
|
||||
line(&mut out, &format!("X-WR-TIMEZONE:{}", escape(&company.timezone)));
|
||||
line(&mut out, &format!("REFRESH-INTERVAL;VALUE=DURATION:{REFRESH_INTERVAL}"));
|
||||
line(&mut out, &format!("X-PUBLISHED-TTL:{REFRESH_INTERVAL}"));
|
||||
for appointment in appointments {
|
||||
event(&mut out, company, appointment);
|
||||
}
|
||||
line(&mut out, "END:VCALENDAR");
|
||||
out
|
||||
}
|
||||
|
||||
/// ETag do corpo, por FNV-1a de 64 bits.
|
||||
///
|
||||
/// O Apple busca de hora em hora, por assinante: o 304 e o que faz esse trafego
|
||||
/// custar quase nada. Sai do CORPO e nao de um contador ou da data mais recente,
|
||||
/// porque o corpo e exatamente a coisa que precisa estar igual. FNV escrito aqui
|
||||
/// porque nao ha crate de hash nas dependencias e o uso nao e criptografico:
|
||||
/// quem forjar um ETag so consegue ver a propria agenda desatualizada.
|
||||
pub fn etag(body: &str) -> String {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in body.as_bytes() {
|
||||
hash ^= *byte as u64;
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("\"{hash:016x}\"")
|
||||
}
|
||||
|
||||
fn event(out: &mut String, company: &Company, appointment: &CalendarAppointment) {
|
||||
line(out, "BEGIN:VEVENT");
|
||||
line(out, &format!("UID:appointment-{}@{UID_DOMAIN}", appointment.id));
|
||||
// DTSTAMP e o `updated_at` da linha, nunca `now()`: com a hora da requisicao
|
||||
// o corpo mudaria a cada busca e o ETag nunca casaria.
|
||||
line(out, &format!("DTSTAMP:{}", instant(appointment.updated_at)));
|
||||
line(out, &format!("DTSTART:{}", instant(appointment.start)));
|
||||
line(out, &format!("DTEND:{}", instant(appointment.end)));
|
||||
line(out, &format!("SUMMARY:{}", escape(&summary(appointment))));
|
||||
line(out, &format!("DESCRIPTION:{}", escape(&description(appointment))));
|
||||
if !company.address.is_empty() {
|
||||
line(out, &format!("LOCATION:{}", escape(&company.address)));
|
||||
}
|
||||
line(out, "END:VEVENT");
|
||||
}
|
||||
|
||||
/// O que se le na grade do celular, onde cabe pouco: quem vem, e para que.
|
||||
fn summary(appointment: &CalendarAppointment) -> String {
|
||||
match &appointment.service_name {
|
||||
Some(service) => format!("{} - {}", appointment.client_name, service),
|
||||
// Encaixe sem Service: so o nome, sem separador orfao.
|
||||
None => appointment.client_name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// O detalhe de quem abriu o evento. O telefone vem primeiro: e o que se usa
|
||||
/// quando o cliente atrasa.
|
||||
fn description(appointment: &CalendarAppointment) -> String {
|
||||
let mut lines = vec![format!("Telefone: {}", appointment.client_phone)];
|
||||
if let Some(service) = &appointment.service_name {
|
||||
// O preco e o SNAPSHOT do agendamento, nunca o catalogo de hoje.
|
||||
lines.push(match appointment.price_cents {
|
||||
Some(cents) => format!("Serviço: {} ({})", service, money(cents)),
|
||||
None => format!("Serviço: {service}"),
|
||||
});
|
||||
}
|
||||
if let Some(note) = appointment.description.as_deref().filter(|n| !n.is_empty()) {
|
||||
lines.push(format!("Observação: {note}"));
|
||||
}
|
||||
if let Some(happened) = outcome(&appointment.status) {
|
||||
lines.push(happened.to_string());
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// O que o feed conta sobre horario que ja passou. `confirmed` nao precisa dizer
|
||||
/// nada; os dois estados de historico precisam, porque continuam no calendario e
|
||||
/// "compareceu" e "faltou" nao podem ficar iguais.
|
||||
fn outcome(status: &str) -> Option<&'static str> {
|
||||
match AppointmentStatus::try_parse(status) {
|
||||
Some(AppointmentStatus::Completed) => Some("Atendimento realizado"),
|
||||
Some(AppointmentStatus::NoShow) => Some("Cliente não compareceu"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A forma UTC do RFC 5545: `20300902T140000Z`.
|
||||
fn instant(at: DateTime<Utc>) -> String {
|
||||
at.format("%Y%m%dT%H%M%SZ").to_string()
|
||||
}
|
||||
|
||||
/// Escape de valor TEXT (RFC 5545 3.3.11). Nome de cliente e endereco sao
|
||||
/// entrada de usuario: um `;` ou um `,` cru viram separador de campo e o evento
|
||||
/// chega truncado ou o arquivo inteiro e recusado.
|
||||
fn escape(raw: &str) -> String {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
for c in raw.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
';' => out.push_str("\\;"),
|
||||
',' => out.push_str("\\,"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
// CR solto nao existe no formato, e um \r\n vindo do banco ja virou
|
||||
// \n na linha acima.
|
||||
'\r' => {}
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Escreve uma linha logica dobrada, terminada em CRLF.
|
||||
///
|
||||
/// RFC 5545 3.1: linha maior que 75 octetos se quebra em CRLF mais um espaco, e
|
||||
/// esse espaco CONTA nos 75 da continuacao. Conta octetos, nao caracteres, e
|
||||
/// nunca parte uma sequencia UTF-8: cortar por byte deixaria um "ç" pela metade
|
||||
/// e o aplicativo recusa o calendario inteiro, nao so o evento.
|
||||
fn line(out: &mut String, content: &str) {
|
||||
let mut used = 0;
|
||||
for c in content.chars() {
|
||||
let len = c.len_utf8();
|
||||
if used + len > FOLD_LIMIT {
|
||||
out.push_str("\r\n ");
|
||||
used = 1;
|
||||
}
|
||||
out.push(c);
|
||||
used += len;
|
||||
}
|
||||
out.push_str("\r\n");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn company() -> Company {
|
||||
Company {
|
||||
id: 1,
|
||||
name: "Barbearia Possebom".to_string(),
|
||||
slug: "possebom".to_string(),
|
||||
timezone: "America/Sao_Paulo".to_string(),
|
||||
active: true,
|
||||
min_lead_minutes: 60,
|
||||
max_horizon_days: 60,
|
||||
reminder_hours: 24,
|
||||
logo: String::new(),
|
||||
address: String::new(),
|
||||
booking_flow: "service_first".to_string(),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn at(rfc3339: &str) -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339(rfc3339).expect("instante valido").with_timezone(&Utc)
|
||||
}
|
||||
|
||||
fn appointment() -> CalendarAppointment {
|
||||
CalendarAppointment {
|
||||
id: 99,
|
||||
start: at("2030-09-02T14:00:00Z"),
|
||||
end: at("2030-09-02T14:30:00Z"),
|
||||
updated_at: at("2030-08-20T10:00:00Z"),
|
||||
status: "confirmed".to_string(),
|
||||
description: None,
|
||||
price_cents: Some(9900),
|
||||
client_name: "Maria".to_string(),
|
||||
client_phone: "41999998888".to_string(),
|
||||
service_name: Some("Corte".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Desdobra o corpo como o aplicativo de calendario faz, para conferir o
|
||||
/// texto logico depois da dobra.
|
||||
fn unfolded(body: &str) -> String {
|
||||
body.replace("\r\n ", "")
|
||||
}
|
||||
|
||||
fn value_of(body: &str, property: &str) -> String {
|
||||
unfolded(body)
|
||||
.lines()
|
||||
.find(|l| l.starts_with(&format!("{property}:")))
|
||||
.map(|l| l[property.len() + 1..].to_string())
|
||||
.unwrap_or_else(|| panic!("nao achei {property} em:\n{body}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_calendar_says_who_it_is_and_how_often_to_come_back() {
|
||||
let body = feed(&company(), &[appointment()]);
|
||||
assert!(body.starts_with("BEGIN:VCALENDAR\r\n"), "{body}");
|
||||
assert!(body.ends_with("END:VCALENDAR\r\n"), "{body}");
|
||||
assert!(body.contains("VERSION:2.0\r\n"));
|
||||
assert!(body.contains(&format!("PRODID:{PRODID}\r\n")));
|
||||
assert_eq!(value_of(&body, "X-WR-CALNAME"), "Barbearia Possebom");
|
||||
assert_eq!(value_of(&body, "X-WR-TIMEZONE"), "America/Sao_Paulo");
|
||||
// Os dois pedidos de intervalo: o padrao e o que o Apple entende.
|
||||
assert!(body.contains("REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\n"), "{body}");
|
||||
assert!(body.contains("X-PUBLISHED-TTL:PT1H\r\n"), "{body}");
|
||||
// Toda quebra e CRLF, nunca LF solto.
|
||||
assert!(!body.replace("\r\n", "").contains('\n'), "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_event_carries_the_client_the_service_and_the_snapshot_price() {
|
||||
let body = feed(&company(), &[appointment()]);
|
||||
assert_eq!(value_of(&body, "UID"), "appointment-99@vemmarcar.com");
|
||||
assert_eq!(value_of(&body, "DTSTART"), "20300902T140000Z");
|
||||
assert_eq!(value_of(&body, "DTEND"), "20300902T143000Z");
|
||||
assert_eq!(value_of(&body, "DTSTAMP"), "20300820T100000Z");
|
||||
assert_eq!(value_of(&body, "SUMMARY"), "Maria - Corte");
|
||||
let description = value_of(&body, "DESCRIPTION");
|
||||
assert!(description.contains("Telefone: 41999998888"), "{description}");
|
||||
assert!(description.contains("Serviço: Corte (R$ 99\\,00)"), "{description}");
|
||||
}
|
||||
|
||||
/// A armadilha do formato: 75 OCTETOS, e um "ç" ocupa dois. Cortar por byte
|
||||
/// parte o caractere e o aplicativo recusa o arquivo inteiro.
|
||||
#[test]
|
||||
fn no_line_passes_75_octets_and_no_character_is_split_in_half() {
|
||||
let mut long = appointment();
|
||||
long.client_name = "Maria Aparecida Gonçalves de Assunção Nascimento Conceição Sobrinho".to_string();
|
||||
long.service_name = Some("Corte, barba e hidratação com finalização especial".to_string());
|
||||
long.description = Some("Alérgica a amônia; chegar dez minutos antes".to_string());
|
||||
let mut c = company();
|
||||
c.address = "Rua das Flores, 123 - Centro, São José dos Pinhais - PR, CEP 83005-000".to_string();
|
||||
|
||||
let body = feed(&c, &[long]);
|
||||
|
||||
// 75 cravado, NAO a constante: um teste que le FOLD_LIMIT passa junto com
|
||||
// ela se alguem a afrouxar, e ai nao mede mais nada.
|
||||
for physical in body.split("\r\n").filter(|l| !l.is_empty()) {
|
||||
assert!(physical.len() <= 75, "linha com {} octetos: {physical:?}", physical.len());
|
||||
}
|
||||
// Toda continuacao comeca por um espaco, que e o que a diz continuacao.
|
||||
assert!(body.contains("\r\n "), "nada dobrou:\n{body}");
|
||||
// E o texto logico sobrevive inteiro, acentos incluidos.
|
||||
let description = value_of(&body, "DESCRIPTION");
|
||||
assert!(description.contains("Alérgica a amônia"), "{description}");
|
||||
assert!(value_of(&body, "SUMMARY").contains("Gonçalves de Assunção"), "{body}");
|
||||
assert!(value_of(&body, "LOCATION").contains("São José dos Pinhais"), "{body}");
|
||||
}
|
||||
|
||||
/// `;` e `,` sao separadores do formato: crus, truncam o evento.
|
||||
#[test]
|
||||
fn a_client_name_with_separators_is_escaped_instead_of_breaking_the_event() {
|
||||
let mut tricky = appointment();
|
||||
tricky.client_name = "Silva, Maria; Cia \\ Ltda".to_string();
|
||||
tricky.description = Some("primeira linha\nsegunda linha".to_string());
|
||||
|
||||
let body = feed(&company(), &[tricky]);
|
||||
|
||||
assert_eq!(value_of(&body, "SUMMARY"), "Silva\\, Maria\\; Cia \\\\ Ltda - Corte");
|
||||
// Newline vira o \n do formato, e nunca uma quebra de verdade: quebra
|
||||
// crua encerraria a propriedade no meio.
|
||||
let description = value_of(&body, "DESCRIPTION");
|
||||
assert!(description.contains("primeira linha\\nsegunda linha"), "{description}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_uid_is_stable_so_rescheduling_moves_the_event_instead_of_cloning_it() {
|
||||
let before = feed(&company(), &[appointment()]);
|
||||
let mut moved = appointment();
|
||||
moved.start = at("2030-09-03T18:00:00Z");
|
||||
moved.end = at("2030-09-03T18:30:00Z");
|
||||
moved.updated_at = at("2030-08-25T09:00:00Z");
|
||||
let after = feed(&company(), &[moved]);
|
||||
|
||||
assert_eq!(value_of(&before, "UID"), value_of(&after, "UID"));
|
||||
assert_ne!(value_of(&before, "DTSTART"), value_of(&after, "DTSTART"));
|
||||
}
|
||||
|
||||
/// O ETag so serve se o mesmo estado produzir o mesmo corpo: nada de
|
||||
/// `now()` no DTSTAMP.
|
||||
#[test]
|
||||
fn the_same_agenda_produces_the_same_body_and_the_same_etag() {
|
||||
let first = feed(&company(), &[appointment()]);
|
||||
let second = feed(&company(), &[appointment()]);
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(etag(&first), etag(&second));
|
||||
|
||||
let mut touched = appointment();
|
||||
touched.updated_at = at("2030-08-21T10:00:00Z");
|
||||
assert_ne!(etag(&first), etag(&feed(&company(), &[touched])));
|
||||
// E esvaziar a agenda tambem muda o ETag, senao o celular nunca apaga.
|
||||
assert_ne!(etag(&first), etag(&feed(&company(), &[])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_walk_in_without_service_shows_only_the_client() {
|
||||
let mut walk_in = appointment();
|
||||
walk_in.service_name = None;
|
||||
walk_in.price_cents = None;
|
||||
let body = feed(&company(), &[walk_in]);
|
||||
|
||||
assert_eq!(value_of(&body, "SUMMARY"), "Maria");
|
||||
let description = value_of(&body, "DESCRIPTION");
|
||||
assert!(!description.contains("Serviço"), "{description}");
|
||||
assert!(!description.contains("R$"), "{description}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_past_day_says_whether_the_client_showed_up() {
|
||||
let mut done = appointment();
|
||||
done.status = "completed".to_string();
|
||||
assert!(value_of(&feed(&company(), &[done]), "DESCRIPTION").contains("Atendimento realizado"));
|
||||
|
||||
let mut missed = appointment();
|
||||
missed.status = "no_show".to_string();
|
||||
assert!(value_of(&feed(&company(), &[missed]), "DESCRIPTION").contains("Cliente não compareceu"));
|
||||
|
||||
// Horario que ainda vai acontecer nao carrega desfecho nenhum.
|
||||
let description = value_of(&feed(&company(), &[appointment()]), "DESCRIPTION");
|
||||
assert!(!description.contains("realizado"), "{description}");
|
||||
assert!(!description.contains("compareceu"), "{description}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_address_becomes_the_location_and_its_absence_leaves_no_empty_line() {
|
||||
let mut with_address = company();
|
||||
with_address.address = "Rua das Flores, 123".to_string();
|
||||
let body = feed(&with_address, &[appointment()]);
|
||||
assert_eq!(value_of(&body, "LOCATION"), "Rua das Flores\\, 123");
|
||||
|
||||
assert!(!feed(&company(), &[appointment()]).contains("LOCATION"), "endereco vazio nao gera propriedade");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_agenda_is_still_a_valid_calendar() {
|
||||
// Profissional novo, ou ativo sem nada marcado: o aplicativo precisa de
|
||||
// um VCALENDAR valido, nao de uma resposta vazia.
|
||||
let body = feed(&company(), &[]);
|
||||
assert!(body.starts_with("BEGIN:VCALENDAR\r\n"));
|
||||
assert!(body.ends_with("END:VCALENDAR\r\n"));
|
||||
assert!(!body.contains("BEGIN:VEVENT"));
|
||||
}
|
||||
}
|
||||
+10
@@ -41,6 +41,7 @@ mod confirmation_code;
|
||||
mod controllers;
|
||||
mod error;
|
||||
mod expiration;
|
||||
mod icalendar;
|
||||
mod magic_link;
|
||||
mod mailer;
|
||||
mod models;
|
||||
@@ -247,6 +248,10 @@ pub fn public_routes() -> Router<Arc<AppState>> {
|
||||
.route("/appointment/{token}", get(controllers::public::get_appointment))
|
||||
.route("/appointment/{token}", delete(controllers::public::cancel_appointment))
|
||||
.route("/appointment/confirm/{token}", post(controllers::public::confirm_appointment))
|
||||
// O Calendar feed (ADR 0006). O `.ics` vive num segmento proprio porque
|
||||
// o matchit (o roteador do axum) nao aceita sufixo depois de um
|
||||
// parametro: `{token}.ics` faz o Router entrar em panico no boot.
|
||||
.route("/calendar/{token}/feed.ics", get(controllers::public::calendar_feed))
|
||||
.layer(GovernorLayer::new(governor))
|
||||
}
|
||||
|
||||
@@ -338,6 +343,11 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
get(controllers::professional::get_professionals_by_company_id),
|
||||
)
|
||||
.route("/professional/find_by_email/{email}", get(controllers::professional::get_professional_by_email))
|
||||
// O Calendar feed do proprio profissional (ADR 0006): ver o link, trocar
|
||||
// o link, receber o link por e-mail.
|
||||
.route("/professional/{id}/calendar_feed", get(controllers::professional::get_calendar_feed))
|
||||
.route("/professional/{id}/calendar_feed/rotate", post(controllers::professional::rotate_calendar_feed))
|
||||
.route("/professional/{id}/calendar_feed/email", post(controllers::professional::email_calendar_feed))
|
||||
.route("/me", get(controllers::professional::show_logged_profile))
|
||||
.route("/appointment", post(controllers::appointment::create_appointment))
|
||||
.route("/appointment/{id}", patch(controllers::appointment::update))
|
||||
|
||||
@@ -69,6 +69,22 @@ impl AppointmentStatus {
|
||||
AppointmentStatus::Expired,
|
||||
];
|
||||
|
||||
/// O que o Calendar feed publica (ADR 0006). `completed` e `no_show`
|
||||
/// chegam DEPOIS da hora, e filtrar so `confirmed` apagaria do calendario
|
||||
/// justamente o dia que o Professional acabou de trabalhar. `pending` fica
|
||||
/// fora: com TTL de 15 minutos contra busca de hora em hora, seria quase
|
||||
/// sempre fantasma de horario que ja expirou.
|
||||
///
|
||||
/// Vive aqui, e nao como literal no SQL, porque e a decisao do ADR: o
|
||||
/// handler passa esta lista ao repositorio, e assim ela e legivel e
|
||||
/// testavel em Rust.
|
||||
pub const CALENDAR_FEED: [AppointmentStatus; 3] = [AppointmentStatus::Confirmed, AppointmentStatus::Completed, AppointmentStatus::NoShow];
|
||||
|
||||
/// A grafia que o banco guarda, para os `= ANY($n)`.
|
||||
pub fn labels(list: &[AppointmentStatus]) -> Vec<String> {
|
||||
list.iter().map(|s| s.as_str().to_string()).collect()
|
||||
}
|
||||
|
||||
/// Entrada do usuario: desconhecido e recusado, nunca adivinhado.
|
||||
pub fn try_parse(raw: &str) -> Option<Self> {
|
||||
match raw {
|
||||
@@ -97,6 +113,29 @@ impl AppointmentStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Uma linha do Calendar feed: o Appointment com o que o evento precisa mostrar
|
||||
/// ja resolvido no SQL (ADR 0006).
|
||||
///
|
||||
/// Nao serializa: isto nunca sai como JSON, so como texto iCalendar. `status`
|
||||
/// vem junto porque `completed` e `no_show` viram uma linha de descricao, e
|
||||
/// `updated_at` porque e o `DTSTAMP` do evento (usar `now()` faria o corpo
|
||||
/// mudar a cada busca e o ETag nunca casar).
|
||||
#[derive(PartialEq, Debug, sqlx::FromRow)]
|
||||
pub struct CalendarAppointment {
|
||||
pub id: i32,
|
||||
pub start: chrono::DateTime<chrono::Utc>,
|
||||
pub end: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub status: String,
|
||||
pub description: Option<String>,
|
||||
/// Snapshot do preco combinado; `None` = encaixe sem servico.
|
||||
pub price_cents: Option<i32>,
|
||||
pub client_name: String,
|
||||
pub client_phone: String,
|
||||
/// `None` = encaixe: agendamento sem Service.
|
||||
pub service_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct NewAppointment {
|
||||
pub start: chrono::DateTime<chrono::Utc>,
|
||||
|
||||
@@ -40,6 +40,14 @@ pub struct Professional {
|
||||
pub company_id: i32,
|
||||
#[serde(default)]
|
||||
pub avatar: String,
|
||||
/// Credencial do Calendar feed (ADR 0006). `None` = ainda nao pediu o link.
|
||||
///
|
||||
/// Nunca sai em resposta: quem tem este token le a agenda inteira com nome
|
||||
/// e telefone dos clientes, e as rotas do painel (`/me`, `/professionals`,
|
||||
/// `/professional/{id}`) devolvem o model cru. Nao entra por corpo nenhum,
|
||||
/// so pelos dois metodos do repositorio que o geram.
|
||||
#[serde(default, skip_serializing, skip_deserializing)]
|
||||
pub calendar_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, sqlx::FromRow)]
|
||||
|
||||
+65
-4
@@ -13,6 +13,7 @@ use chrono_tz::Tz;
|
||||
use crate::{
|
||||
mailer::{DynMailer, Email},
|
||||
models::{appointment::Appointment, client::Client, company::Company, professional::Professional, service::Service},
|
||||
utils::money,
|
||||
};
|
||||
|
||||
/// Hora local da empresa, formatada para gente ler. O cliente nunca deve ver UTC.
|
||||
@@ -24,10 +25,6 @@ fn local(instant: DateTime<Utc>, timezone: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn money(cents: i32) -> String {
|
||||
format!("R$ {},{:02}", cents / 100, (cents % 100).abs())
|
||||
}
|
||||
|
||||
/// A linha de servico com o preco do SNAPSHOT do agendamento quando ha um: o
|
||||
/// valor combinado na marcacao, nunca o catalogo atual (services.price_cents
|
||||
/// pode ter mudado depois, e ha preco por profissional). Snapshot ausente
|
||||
@@ -356,6 +353,37 @@ pub fn professional_booked(company: &Company, professional: &Professional, clien
|
||||
})
|
||||
}
|
||||
|
||||
/// O link do Calendar feed para o proprio Professional (ADR 0006).
|
||||
///
|
||||
/// Devolve `Email`, e nao `Option<Email>` como os avisos: aqui o destinatario sem
|
||||
/// e-mail nao e situacao normal e sim pedido que nao da para atender, e quem
|
||||
/// responde 400 e o handler antes de chegar aqui.
|
||||
///
|
||||
/// A copy diz a verdade de cada plataforma, porque a assimetria e grande: o
|
||||
/// iPhone assina com um toque e busca de hora em hora; no Google Agenda assinar
|
||||
/// por URL so existe no navegador de computador, e a agenda pode levar um dia
|
||||
/// para atualizar. Prometer menos aqui evita o suporte depois.
|
||||
pub fn calendar_feed(company: &Company, professional: &Professional, url: &str, webcal_url: &str) -> Email {
|
||||
Email {
|
||||
to: professional.email.clone(),
|
||||
subject: format!("Sua agenda de {} no calendario do celular", company.name),
|
||||
html: None,
|
||||
body: format!(
|
||||
"Ola, {}!\n\n\
|
||||
Este link publica a sua agenda de {} no seu calendario. Guarde-o: quem tiver \
|
||||
o link ve os seus horarios, com nome e telefone dos clientes.\n\n\
|
||||
No iPhone ou no iPad, abra este link e toque em assinar:\n{}\n\n\
|
||||
No Google Agenda, e preciso um computador (o aplicativo do celular nao assina \
|
||||
calendario por URL): abra calendar.google.com, em \"Outros calendarios\" escolha \
|
||||
\"Assinar um calendario\" e cole este endereco:\n{}\n\n\
|
||||
O iPhone atualiza de hora em hora; o Google pode levar ate um dia. A agenda entra \
|
||||
como somente leitura, e marcacao e cancelamento continuam chegando por e-mail na \
|
||||
hora.\n",
|
||||
professional.name, company.name, webcal_url, url,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn professional_cancelled(company: &Company, professional: &Professional, client: &Client, appointment: &Appointment) -> Option<Email> {
|
||||
if professional.email.is_empty() {
|
||||
return None;
|
||||
@@ -710,6 +738,39 @@ mod tests {
|
||||
assert!(email.body.contains("14:00 UTC"), "corpo: {}", email.body);
|
||||
}
|
||||
|
||||
/// A copy do feed tem que dizer a verdade das duas plataformas: o iPhone
|
||||
/// assina com um toque, o Google exige um computador e atrasa. Prometer
|
||||
/// sincronia imediata aqui viraria suporte depois (ADR 0006).
|
||||
#[test]
|
||||
fn the_calendar_link_tells_the_truth_about_each_platform() {
|
||||
let email = calendar_feed(
|
||||
&company(),
|
||||
&professional("p@e.com"),
|
||||
"https://vemmarcar.com/api/v1/public/calendar/ABC/feed.ics",
|
||||
"webcal://vemmarcar.com/api/v1/public/calendar/ABC/feed.ics",
|
||||
);
|
||||
|
||||
assert_eq!(email.to, "p@e.com");
|
||||
// O webcal e o link do iPhone; a URL https e a que se cola no Google.
|
||||
assert!(
|
||||
email.body.contains("webcal://vemmarcar.com/api/v1/public/calendar/ABC/feed.ics"),
|
||||
"corpo: {}",
|
||||
email.body
|
||||
);
|
||||
assert!(
|
||||
email.body.contains("https://vemmarcar.com/api/v1/public/calendar/ABC/feed.ics"),
|
||||
"corpo: {}",
|
||||
email.body
|
||||
);
|
||||
assert!(email.body.contains("computador"), "corpo: {}", email.body);
|
||||
assert!(email.body.contains("ate um dia"), "corpo: {}", email.body);
|
||||
// Quem tem o link ve nome e telefone dos clientes: isso precisa estar
|
||||
// escrito para o link nao ser repassado sem pensar.
|
||||
assert!(email.body.contains("telefone"), "corpo: {}", email.body);
|
||||
// Aviso interno segue texto puro, como os outros do profissional.
|
||||
assert!(email.html.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_formats_cents_with_two_digits() {
|
||||
assert_eq!(money(5000), "R$ 50,00");
|
||||
|
||||
@@ -8,7 +8,7 @@ use sqlx::PgPool;
|
||||
use crate::{
|
||||
error::DataAccessError,
|
||||
models::{
|
||||
appointment::{Appointment, AppointmentStatus, NewAppointment, UpdateAppointment},
|
||||
appointment::{Appointment, AppointmentStatus, CalendarAppointment, NewAppointment, UpdateAppointment},
|
||||
returning::Returning,
|
||||
},
|
||||
};
|
||||
@@ -55,6 +55,19 @@ pub trait AppointmentRepository {
|
||||
/// condicionado, duas varreduras concorrentes nao brigam.
|
||||
async fn expire_overdue(&self) -> Result<u64, DataAccessError>;
|
||||
async fn get_appointments_by_professional_id(&self, professional_id: i32) -> Result<Vec<Appointment>, DataAccessError>;
|
||||
/// A agenda que o Calendar feed publica (ADR 0006): de `from` para frente,
|
||||
/// sem teto no futuro, com o Client e o Service ja resolvidos.
|
||||
///
|
||||
/// `statuses` vem do chamador, de `AppointmentStatus::CALENDAR_FEED`: a
|
||||
/// decisao de quem entra e do ADR, e literal cravado no SQL nao da para ler
|
||||
/// nem para testar sem banco. Sem teto de futuro de proposito: o feed e o
|
||||
/// estado completo, e o que sai dele o celular apaga.
|
||||
async fn get_calendar_feed(
|
||||
&self,
|
||||
professional_id: i32,
|
||||
from: chrono::DateTime<chrono::Utc>,
|
||||
statuses: Vec<String>,
|
||||
) -> Result<Vec<CalendarAppointment>, DataAccessError>;
|
||||
/// So o que ocupa a agenda, e so na janela pedida. Espelha o WHERE da
|
||||
/// constraint appointments_no_overlap.
|
||||
async fn get_busy_between(
|
||||
@@ -221,6 +234,41 @@ impl AppointmentRepository for PgAppointmentRepository {
|
||||
.map_err(|e| DataAccessError::from_sqlx("appointment::get_by_professional", e))
|
||||
}
|
||||
|
||||
async fn get_calendar_feed(
|
||||
&self,
|
||||
professional_id: i32,
|
||||
from: chrono::DateTime<chrono::Utc>,
|
||||
statuses: Vec<String>,
|
||||
) -> Result<Vec<CalendarAppointment>, DataAccessError> {
|
||||
sqlx::query_as!(
|
||||
CalendarAppointment,
|
||||
r#"--sql
|
||||
SELECT
|
||||
a.id,
|
||||
a."start",
|
||||
a."end",
|
||||
a.updated_at,
|
||||
a.status,
|
||||
a.description,
|
||||
a.price_cents,
|
||||
c.name AS client_name,
|
||||
c.phone AS client_phone,
|
||||
s.name AS "service_name?"
|
||||
FROM appointments a
|
||||
JOIN clients c ON c.id = a.client_id
|
||||
LEFT JOIN services s ON s.id = a.service_id
|
||||
WHERE a.professional_id = $1 AND a."start" >= $2 AND a.status = ANY($3)
|
||||
ORDER BY a."start"
|
||||
"#,
|
||||
professional_id,
|
||||
from,
|
||||
&statuses
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DataAccessError::from_sqlx("appointment::get_calendar_feed", e))
|
||||
}
|
||||
|
||||
async fn claim_due_reminders(&self) -> Result<Vec<Appointment>, DataAccessError> {
|
||||
// A janela de lembrete e por empresa, entao o corte depende do JOIN.
|
||||
// FOR UPDATE SKIP LOCKED impede duas varreduras concorrentes de brigar
|
||||
|
||||
@@ -39,6 +39,18 @@ pub trait ProfessionalRepository {
|
||||
/// Todos os ids de uma vez: a listagem publica fazia uma query por id da
|
||||
/// lista (bug 83).
|
||||
async fn get_by_ids(&self, ids: Vec<i32>) -> Result<Vec<Professional>, DataAccessError>;
|
||||
/// Garante que a linha tem `calendar_token` e o devolve (ADR 0006). Mesmo
|
||||
/// contrato do `ensure_access_code`: o candidato so entra se ainda nao ha
|
||||
/// token, entao dois pedidos concorrentes convergem para o que o banco
|
||||
/// guardou e um link ja entregue ao Google nunca troca por acidente.
|
||||
async fn ensure_calendar_token(&self, id: i32, candidate: String) -> Result<String, DataAccessError>;
|
||||
/// Troca o token: e a UNICA revogacao que existe, porque a URL antiga
|
||||
/// continua na mao de quem a recebeu.
|
||||
async fn rotate_calendar_token(&self, id: i32, token: String) -> Result<String, DataAccessError>;
|
||||
/// O lookup do feed. `NotFound` cobre token desconhecido; profissional
|
||||
/// inativo volta normal e quem recusa e o handler, para o 404 ser o mesmo
|
||||
/// nos dois casos.
|
||||
async fn get_by_calendar_token(&self, token: String) -> Result<Professional, DataAccessError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -262,6 +274,61 @@ impl ProfessionalRepository for PgProfessionalRepository {
|
||||
.map_err(|e| DataAccessError::from_sqlx("professional::get_by_ids", e))
|
||||
}
|
||||
|
||||
async fn ensure_calendar_token(&self, id: i32, candidate: String) -> Result<String, DataAccessError> {
|
||||
let row = sqlx::query!(
|
||||
r#"--sql
|
||||
UPDATE professionals SET calendar_token = COALESCE(calendar_token, $2)
|
||||
WHERE id = $1
|
||||
RETURNING calendar_token
|
||||
"#,
|
||||
id,
|
||||
candidate
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|err| DataAccessError::from_sqlx("professional::ensure_calendar_token", err))?;
|
||||
match row {
|
||||
// O COALESCE garante valor; NULL aqui seria o proprio UPDATE quebrado.
|
||||
Some(row) => row.calendar_token.ok_or(DataAccessError::TechnicalError),
|
||||
None => Err(DataAccessError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
async fn rotate_calendar_token(&self, id: i32, token: String) -> Result<String, DataAccessError> {
|
||||
let row = sqlx::query!(
|
||||
r#"--sql
|
||||
UPDATE professionals SET calendar_token = $2 WHERE id = $1
|
||||
RETURNING calendar_token
|
||||
"#,
|
||||
id,
|
||||
token
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|err| DataAccessError::from_sqlx("professional::rotate_calendar_token", err))?;
|
||||
match row {
|
||||
Some(row) => row.calendar_token.ok_or(DataAccessError::TechnicalError),
|
||||
None => Err(DataAccessError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_by_calendar_token(&self, token: String) -> Result<Professional, DataAccessError> {
|
||||
let professional = sqlx::query_as!(
|
||||
Professional,
|
||||
r#"--sql
|
||||
SELECT * FROM professionals WHERE calendar_token = $1
|
||||
"#,
|
||||
token
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|err| DataAccessError::from_sqlx("professional::get_by_calendar_token", err))?;
|
||||
match professional {
|
||||
Some(professional) => Ok(professional),
|
||||
None => Err(DataAccessError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_last_activity(&self, id: i32) -> Result<SessionState, DataAccessError> {
|
||||
let row = sqlx::query!(
|
||||
"UPDATE professionals SET last_activity = now() WHERE id = $1 RETURNING active, role, company_id, password_changed_at",
|
||||
|
||||
@@ -26,6 +26,7 @@ pub fn generate_professional() -> Professional {
|
||||
phone: "123".to_string(),
|
||||
active: true,
|
||||
avatar: "".to_string(),
|
||||
calendar_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ use crate::{
|
||||
AppState, KEYS,
|
||||
};
|
||||
|
||||
/// Centavos como gente le. Mora aqui porque os e-mails e o Calendar feed
|
||||
/// mostram o mesmo preco (o snapshot do agendamento) e nao podem divergir na
|
||||
/// formatacao.
|
||||
pub fn money(cents: i32) -> String {
|
||||
format!("R$ {},{:02}", cents / 100, (cents % 100).abs())
|
||||
}
|
||||
|
||||
pub fn get_timestamp_from_now(hours: u8) -> u64 {
|
||||
let hours = 3600 * hours as u64;
|
||||
let now = SystemTime::now();
|
||||
|
||||
@@ -455,6 +455,64 @@ async fn the_public_team_hides_whoever_offers_no_active_service(pool: PgPool) {
|
||||
assert_eq!(visible, vec![50]);
|
||||
}
|
||||
|
||||
/// O filtro do Calendar feed roda no SQL, e teste com mock nunca o executa.
|
||||
///
|
||||
/// Aqui e onde se prova que `pending`, `expired` e os dois cancelados NAO saem no
|
||||
/// feed (pending com TTL de 15 minutos contra busca de hora em hora seria quase
|
||||
/// sempre fantasma) e que o corte de 90 dias no passado vale (ADR 0006).
|
||||
///
|
||||
/// O SQL aqui e copia do `appointment::get_calendar_feed`, porque teste de
|
||||
/// integracao nao alcanca os modulos do binario: mexeu num, mexa no outro.
|
||||
#[sqlx::test]
|
||||
async fn the_calendar_feed_query_returns_only_the_three_published_statuses(pool: PgPool) {
|
||||
// Um horario por status, cada um em dia diferente para nao bater na
|
||||
// constraint de sobreposicao.
|
||||
let statuses = [
|
||||
"confirmed",
|
||||
"completed",
|
||||
"no_show",
|
||||
"pending",
|
||||
"expired",
|
||||
"cancelled_by_client",
|
||||
"cancelled_by_company",
|
||||
];
|
||||
for (day, status) in statuses.iter().enumerate() {
|
||||
insert_appointment(
|
||||
&pool,
|
||||
&format!("2030-09-{:02} 10:00+00", day + 1),
|
||||
&format!("2030-09-{:02} 11:00+00", day + 1),
|
||||
status,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("o horario {status} deve entrar: {err}"));
|
||||
}
|
||||
// Fora da janela: passado distante, no estado que o feed publica.
|
||||
insert_appointment(&pool, "2020-01-10 10:00+00", "2020-01-10 11:00+00", "confirmed")
|
||||
.await
|
||||
.expect("o horario antigo deve entrar");
|
||||
|
||||
let published: Vec<String> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT a.status
|
||||
FROM appointments a
|
||||
JOIN clients c ON c.id = a.client_id
|
||||
LEFT JOIN services s ON s.id = a.service_id
|
||||
WHERE a.professional_id = $1 AND a."start" >= $2 AND a.status = ANY($3)
|
||||
ORDER BY a."start"
|
||||
"#,
|
||||
)
|
||||
.bind(1)
|
||||
.bind(chrono::Utc::now() - chrono::Duration::days(90))
|
||||
.bind(vec!["confirmed".to_string(), "completed".to_string(), "no_show".to_string()])
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("a consulta do feed deve rodar");
|
||||
|
||||
// O de 2020 ficou fora pela janela; pending, expired e os cancelados, pelo
|
||||
// status. O seed nao deixa agendamento nenhum.
|
||||
assert_eq!(published, vec!["confirmed", "completed", "no_show"]);
|
||||
}
|
||||
|
||||
/// O padrao do wizard so aceita os dois modos que a pagina sabe desenhar.
|
||||
/// Valor livre aqui viraria uma primeira tela que nao existe.
|
||||
#[sqlx::test]
|
||||
|
||||
Reference in New Issue
Block a user