feat(booking): pending appointments confirm by channel ownership (ADR 0004)
Build and Push Docker Image / build (push) Successful in 4m44s
Continuous integration / Check (push) Successful in 1m31s
Continuous integration / Test Suite (push) Successful in 2m23s
Continuous integration / Rustfmt (push) Successful in 24s
Continuous integration / Clippy (push) Successful in 1m34s

This commit is contained in:
Alexandre Possebom
2026-07-29 10:28:46 -03:00
parent 9f49d8cd02
commit 989e50c613
41 changed files with 2543 additions and 69 deletions
+11
View File
@@ -20,3 +20,14 @@ PORT=3000
# Onde os avatares enviados sao gravados.
UPLOAD_DIR=./uploads
# Canal WhatsApp (ADR 0004). As QUATRO andam juntas: todas vazias = canal
# desligado (agendar por WhatsApp e o webhook respondem 503); preenchidas pela
# metade = o processo nao sobe. A whatsapp-api e o servico Go separado
# (git.possebom.com/alexandre/whatsapp-api), com sessao propria pareada por QR.
#WHATSAPP_API_URL=http://kauai:8091
#WHATSAPP_API_KEY=
# Numero da sessao, DDI + so digitos: e o destino do link wa.me.
#WHATSAPP_NUMBER=5541999998888
# O mesmo valor configurado como WA_WEBHOOK_SECRET na whatsapp-api.
#WHATSAPP_WEBHOOK_SECRET=
@@ -134,6 +134,28 @@
"name": "reminder_sent_at"
}
}
},
{
"ordinal": 12,
"name": "confirmation_code",
"type_info": "Text",
"origin": {
"Table": {
"table": "appointments",
"name": "confirmation_code"
}
}
},
{
"ordinal": 13,
"name": "expires_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "expires_at"
}
}
}
],
"parameters": {
@@ -153,6 +175,8 @@
false,
false,
true,
true,
true,
true
]
},
@@ -0,0 +1,184 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tUPDATE appointments SET status = 'confirmed'\n\t\t\tWHERE confirmation_code = $1 AND status IN ('pending', 'expired')\n\t\t\tRETURNING *\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "id"
}
}
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "created_at"
}
}
},
{
"ordinal": 2,
"name": "start",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "start"
}
}
},
{
"ordinal": 3,
"name": "end",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "end"
}
}
},
{
"ordinal": 4,
"name": "description",
"type_info": "Text",
"origin": {
"Table": {
"table": "appointments",
"name": "description"
}
}
},
{
"ordinal": 5,
"name": "professional_id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "professional_id"
}
}
},
{
"ordinal": 6,
"name": "client_id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "client_id"
}
}
},
{
"ordinal": 7,
"name": "company_id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "company_id"
}
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "updated_at"
}
}
},
{
"ordinal": 9,
"name": "status",
"type_info": "Text",
"origin": {
"Table": {
"table": "appointments",
"name": "status"
}
}
},
{
"ordinal": 10,
"name": "service_id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "service_id"
}
}
},
{
"ordinal": 11,
"name": "reminder_sent_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "reminder_sent_at"
}
}
},
{
"ordinal": 12,
"name": "confirmation_code",
"type_info": "Text",
"origin": {
"Table": {
"table": "appointments",
"name": "confirmation_code"
}
}
},
{
"ordinal": 13,
"name": "expires_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "expires_at"
}
}
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "68f6313005d2cb2a0f9256e2b3fa20452c92641abf887f5d8d487a5f60748d00"
}
@@ -0,0 +1,186 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT * FROM appointments\n\t\t\tWHERE professional_id = $1 AND status IN ('pending', 'confirmed') AND \"start\" < $3 AND \"end\" > $2\n\t\t\tORDER BY \"start\"\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "id"
}
}
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "created_at"
}
}
},
{
"ordinal": 2,
"name": "start",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "start"
}
}
},
{
"ordinal": 3,
"name": "end",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "end"
}
}
},
{
"ordinal": 4,
"name": "description",
"type_info": "Text",
"origin": {
"Table": {
"table": "appointments",
"name": "description"
}
}
},
{
"ordinal": 5,
"name": "professional_id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "professional_id"
}
}
},
{
"ordinal": 6,
"name": "client_id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "client_id"
}
}
},
{
"ordinal": 7,
"name": "company_id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "company_id"
}
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "updated_at"
}
}
},
{
"ordinal": 9,
"name": "status",
"type_info": "Text",
"origin": {
"Table": {
"table": "appointments",
"name": "status"
}
}
},
{
"ordinal": 10,
"name": "service_id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "service_id"
}
}
},
{
"ordinal": 11,
"name": "reminder_sent_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "reminder_sent_at"
}
}
},
{
"ordinal": 12,
"name": "confirmation_code",
"type_info": "Text",
"origin": {
"Table": {
"table": "appointments",
"name": "confirmation_code"
}
}
},
{
"ordinal": 13,
"name": "expires_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "expires_at"
}
}
}
],
"parameters": {
"Left": [
"Int4",
"Timestamptz",
"Timestamptz"
]
},
"nullable": [
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "704abc0414b5538c4d6682804d88f6a3eeeb20744948b75ec1f2cd25ae211c5e"
}
@@ -0,0 +1,184 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tUPDATE appointments SET status = 'confirmed'\n\t\t\tWHERE id = $1 AND status IN ('pending', 'expired')\n\t\t\tRETURNING *\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "id"
}
}
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "created_at"
}
}
},
{
"ordinal": 2,
"name": "start",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "start"
}
}
},
{
"ordinal": 3,
"name": "end",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "end"
}
}
},
{
"ordinal": 4,
"name": "description",
"type_info": "Text",
"origin": {
"Table": {
"table": "appointments",
"name": "description"
}
}
},
{
"ordinal": 5,
"name": "professional_id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "professional_id"
}
}
},
{
"ordinal": 6,
"name": "client_id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "client_id"
}
}
},
{
"ordinal": 7,
"name": "company_id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "company_id"
}
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "updated_at"
}
}
},
{
"ordinal": 9,
"name": "status",
"type_info": "Text",
"origin": {
"Table": {
"table": "appointments",
"name": "status"
}
}
},
{
"ordinal": 10,
"name": "service_id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "service_id"
}
}
},
{
"ordinal": 11,
"name": "reminder_sent_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "reminder_sent_at"
}
}
},
{
"ordinal": 12,
"name": "confirmation_code",
"type_info": "Text",
"origin": {
"Table": {
"table": "appointments",
"name": "confirmation_code"
}
}
},
{
"ordinal": 13,
"name": "expires_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "expires_at"
}
}
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "845c264fb90e5f44eb7a5bf2342ed0d91376ae91d6709a78d63605b978809335"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT * FROM appointments\n\t\t\tWHERE professional_id = $1 AND status = 'confirmed' AND \"start\" < $3 AND \"end\" > $2\n\t\t\tORDER BY \"start\"\n\t\t\t",
"query": "SELECT * FROM appointments WHERE confirmation_code = $1",
"describe": {
"columns": [
{
@@ -134,13 +134,33 @@
"name": "reminder_sent_at"
}
}
},
{
"ordinal": 12,
"name": "confirmation_code",
"type_info": "Text",
"origin": {
"Table": {
"table": "appointments",
"name": "confirmation_code"
}
}
},
{
"ordinal": 13,
"name": "expires_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "expires_at"
}
}
}
],
"parameters": {
"Left": [
"Int4",
"Timestamptz",
"Timestamptz"
"Text"
]
},
"nullable": [
@@ -155,8 +175,10 @@
false,
false,
true,
true,
true,
true
]
},
"hash": "2af5b00edc22d265a71dd0fc614f1bcc094f343b72d7411a0d95ca70498eb5d1"
"hash": "948981516a31c9434739f1d6f621708ca4a2a5498528a9a2b416a53cd8d5d1c4"
}
@@ -134,6 +134,28 @@
"name": "reminder_sent_at"
}
}
},
{
"ordinal": 12,
"name": "confirmation_code",
"type_info": "Text",
"origin": {
"Table": {
"table": "appointments",
"name": "confirmation_code"
}
}
},
{
"ordinal": 13,
"name": "expires_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "expires_at"
}
}
}
],
"parameters": {
@@ -153,6 +175,8 @@
false,
false,
true,
true,
true,
true
]
},
@@ -134,6 +134,28 @@
"name": "reminder_sent_at"
}
}
},
{
"ordinal": 12,
"name": "confirmation_code",
"type_info": "Text",
"origin": {
"Table": {
"table": "appointments",
"name": "confirmation_code"
}
}
},
{
"ordinal": 13,
"name": "expires_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "appointments",
"name": "expires_at"
}
}
}
],
"parameters": {
@@ -153,6 +175,8 @@
false,
false,
true,
true,
true,
true
]
},
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE appointments SET status = 'expired' WHERE status = 'pending' AND expires_at < NOW()",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "bff78a337a670c6a50a1afafe23e3ac830013f354e1f987588ad34550df2f22e"
}
@@ -0,0 +1,36 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tINSERT INTO appointments\n\t\t\t(\"start\", \"end\", \"professional_id\", \"client_id\", \"company_id\", \"service_id\", \"description\", \"status\", \"confirmation_code\", \"expires_at\")\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8, $9)\n\t\t\treturning id\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4",
"origin": {
"Table": {
"table": "appointments",
"name": "id"
}
}
}
],
"parameters": {
"Left": [
"Timestamptz",
"Timestamptz",
"Int4",
"Int4",
"Int4",
"Int4",
"Text",
"Text",
"Timestamptz"
]
},
"nullable": [
false
]
},
"hash": "cc10bf994f89eb7935ed7257ae2c032b9e975856df24e8216ee9060bf0d1b9c3"
}
+14 -6
View File
@@ -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` | 265 testes unitários, não precisa de banco |
| `cargo test --test db_constraints` | 13 testes de constraint, **exigem Postgres** e `DATABASE_URL` |
| `SQLX_OFFLINE=true cargo test --bins` | 289 testes unitários, não precisa de banco |
| `cargo test --test db_constraints` | 17 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é) |
@@ -62,11 +62,18 @@ no módulo puro, não no controller.
- Autenticação é o extractor `Claims` (`src/utils.rs`). **Handler que recebe `claims: Claims`
exige `Authorization: Bearer`; handler sem esse parâmetro é rota pública.** É assim que se lê,
olhando a assinatura, se algo está exposto. Todo `controllers/public.rs` é público de propósito,
mais `auth::login` e `auth::refresh`.
mais `auth::login`, `auth::refresh` e `controllers/webhook.rs` (barrado por `X-Webhook-Secret`,
não por JWT).
- O cliente final nunca autentica. Ele acessa o próprio agendamento por um magic link
(`src/magic_link.rs`), assinado com `MAGIC_LINK_SECRET`, que é **separado do `JWT_SECRET`**. Não
unifique os dois: um segredo só para dois públicos com poderes diferentes faz a separação depender
de o código lembrar de checar a claim certa em todo caminho.
de o código lembrar de checar a claim certa em todo caminho. O mesmo módulo assina o token de
**confirmação** do e-mail com `purpose` próprio (`appointment_confirm`): confirmar não abre nem
cancela, e vice-versa.
- Agendamento público nasce `pending` e só confirma quando o cliente prova a posse do canal:
código pelo WhatsApp (quem envia a mensagem é o CLIENTE, via link wa.me; o servidor só responde) ou
clique no link do e-mail. Racional, estados e prazos no ADR 0004. Painel autenticado cria
`confirmed` direto.
- Esse mesmo extractor grava `last_activity`, ou seja, todo request autenticado faz um UPDATE.
- A política de autorização vive inteira em `src/authz.rs`: enum `Role` (papel desconhecido cai no
menor privilégio) e helpers `require_*` em `Claims`. Cada handler chama a checagem
@@ -104,8 +111,9 @@ no módulo puro, não no controller.
grade de propósito, e isso é intencional: encaixe existe.
- **A checagem de conflito não é a garantia: quem impede sobreposição é a constraint
`appointments_no_overlap` (ADR 0002).** Create e update conferem via `get_busy_between`, cujo
`WHERE` (`status = 'confirmed'` + janela) precisa espelhar o da constraint: se divergir, a
aplicação recusa horário que o banco aceitaria.
`WHERE` (`status IN ('pending', 'confirmed')` + janela) precisa espelhar o da constraint: se
divergir, a aplicação recusa horário que o banco aceitaria. Desde o ADR 0004 o espelho cobre dois
estados; mexeu num, mexa nos dois.
- **Teste verde aqui não quer dizer que funciona.** Os testes mockam os repositórios e montam
`routes()` via `oneshot`, então **nunca executam SQL e nunca sobem o `main.rs`**. Três bugs sérios
desta base passaram por 129 testes verdes:
Generated
+179 -1
View File
@@ -1098,8 +1098,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1123,9 +1125,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -1359,6 +1363,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
@@ -1378,13 +1398,16 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
@@ -1580,6 +1603,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "ipnet"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -1761,6 +1790,12 @@ dependencies = [
"imgref",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2400,6 +2435,62 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.19",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.19",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -2482,6 +2573,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rand_xoshiro"
version = "0.7.0"
@@ -2623,6 +2723,44 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "rgb"
version = "0.8.53"
@@ -2643,6 +2781,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustls"
version = "0.23.42"
@@ -2664,6 +2808,7 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
@@ -2711,13 +2856,15 @@ dependencies = [
"lettre",
"mockall",
"once_cell",
"percent-encoding",
"rand_core 0.6.4",
"reqwest",
"serde",
"serde_json",
"sqlx",
"tokio",
"tower",
"tower-http",
"tower-http 0.7.0",
"tower_governor",
"tracing",
"tracing-subscriber",
@@ -3199,6 +3346,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@@ -3446,6 +3596,24 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
"url",
]
[[package]]
name = "tower-http"
version = "0.7.0"
@@ -3710,6 +3878,16 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
+2
View File
@@ -38,6 +38,8 @@ anyhow = "1.0.70"
image = "0.25"
clap = { version = "4.2.1", features = ["derive", "env"] }
http-body-util = "0.1.0"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
percent-encoding = "2"
[dev-dependencies]
mockall = "0.15"
+21 -5
View File
@@ -4,21 +4,24 @@ Retomado em 2026-07-28, depois de uma pausa desde 2024-03.
## Onde o projeto está
O cliente marca horário pela internet, sem conta, e recebe o e-mail de confirmação com um link para
ver ou cancelar. É o produto que o projeto se propôs a ser.
O cliente marca horário pela internet, sem conta, e o horário nasce `pending`: só confirma quando
ele prova a posse do canal escolhido, mandando o código pelo WhatsApp (link wa.me que ELE envia) ou
clicando no link do e-mail (ADR 0004). Confirmado, recebe o magic link para ver ou cancelar.
```
GET /api/v1/public/{slug} a empresa
GET /api/v1/public/{slug}/services o catálogo
GET /api/v1/public/{slug}/service/{id}/professionals quem atende
GET /api/v1/public/{slug}/professional/{p}/service/{s}/slots/{data}
POST /api/v1/public/{slug}/appointment marcar
GET /api/v1/public/appointment/{token} ver
POST /api/v1/public/{slug}/appointment marcar (nasce pending)
POST /api/v1/public/appointment/confirm/{token} confirmar pelo e-mail
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
```
Do lado autenticado: CRUD de empresa, profissional, cliente, serviço, agenda semanal, férias e
agendamento. **129 testes**, sendo 9 contra Postgres de verdade.
agendamento. **306 testes**, sendo 17 contra Postgres de verdade.
## Fases concluídas
@@ -54,6 +57,15 @@ IP. Cliente encontrado ou criado pelo par (telefone, empresa).
Falha de envio nunca derruba um agendamento.
### Fase 4: confirmação por posse do canal (2026-07-29, ADR 0004)
Agendamento público nasce `pending` com código `AG-XXXXXX` e prazo (`expires_at`: 15 min WhatsApp,
60 min e-mail). Confirma pela mensagem de WhatsApp com o código (webhook da whatsapp-api, serviço Go
separado) ou pelo clique no link do e-mail (token `purpose: appointment_confirm`). `pending` entrou
na constraint de sobreposição; worker devolve o slot vencido à grade como `expired`, e `expired`
ainda confirma enquanto o slot estiver livre. Os e-mails de "confirmado" e o aviso ao profissional
saem na confirmação, não na criação. Canal desligado sem as 4 env `WHATSAPP_*`.
## Próxima fase: frontend
React + shadcn, Vite, repositório separado (ver ADR 0003): `~/Development/typescript/schdlr-web`,
@@ -73,6 +85,10 @@ personalidade, mobile-first.
- Tela de sucesso, nesta hierarquia: resumo, "ver meu agendamento" (o magic link já vem na resposta
do POST), adicionar ao calendário (.ics gerado no navegador), aviso do e-mail enviado,
compartilhar no WhatsApp.
- **Atenção (fase 4 do backend, ADR 0004): o fluxo pós-POST mudou.** O wizard ganha a escolha do
canal, e a tela de sucesso vira tela de "confirme": canal WhatsApp mostra o botão do `wa_link` e
faz polling do `GET /appointment/{token}` até `confirmed`; canal e-mail instrui a abrir o e-mail,
e a URL `/confirmar/{token}` chama o `POST /appointment/confirm/{token}`.
- Visual único do produto com o nome da empresa em destaque; tokens de cor preparados para um dia
aceitar cor por empresa. Sem assinatura de produto no rodapé.
- pt-BR fixo, sem i18n. URLs `/agendar/{slug}` e `/agendamento/{token}`, já fixadas pelo backend.
@@ -0,0 +1,43 @@
# O agendamento público só vira compromisso quando o cliente prova a posse do canal
O agendamento público nasce `pending` e só passa a `confirmed` quando chega a prova de que o canal
de contato é mesmo do cliente: uma mensagem de WhatsApp trazendo o código de confirmação, ou o
clique no link do e-mail. Antes disso não há compromisso, não sai aviso ao profissional e não há
e-mail de "confirmado".
No WhatsApp, **quem inicia a conversa é sempre o cliente**: a resposta do POST traz um link
`wa.me/<número>?text=` com a mensagem pronta (código `AG-XXXXXX` no texto), o cliente envia, e a
whatsapp-api (serviço próprio sobre whatsmeow, não oficial) entrega a mensagem ao backend por
webhook. Isso é deliberado e não é detalhe: número que dispara conversa com desconhecidos é o perfil
clássico de bloqueio por spam no WhatsApp; número que só responde dentro de conversa iniciada pelo
cliente é o caso mais seguro possível. Não adicionar envio proativo sem repensar o canal. De brinde,
a mensagem chega com o JID do remetente: o telefone fica provado sem custo.
No e-mail, o link de confirmação é um token com `purpose` próprio (`appointment_confirm`), assinado
com o mesmo `MAGIC_LINK_SECRET` mas incapaz de abrir ou cancelar o agendamento, assim como o magic
link de acesso não confirma nada. Mesma segregação por propósito do ADR 0001.
`pending` entra na constraint `appointments_no_overlap` (ADR 0002) ao lado de `confirmed`. A corrida
pelo horário deve acontecer na escolha do slot, quando trocar custa um clique, e não depois de o
cliente já ter enviado a mensagem. O preço é o slot ficar travado esperando a prova, e por isso
existe o prazo: `expires_at` por linha (15 min WhatsApp, 60 min e-mail) e um worker que devolve o
slot à grade marcando `pending` vencido como `expired`.
## Consequences
- O `WHERE` de `get_busy_between` acompanha a constraint: `status IN ('pending', 'confirmed')`.
A regra do ADR 0002 (espelho obrigatório) agora cobre dois estados.
- **`expired` ainda confirma, se o slot seguir livre.** A expiração existe só para devolver o slot;
quem arbitra a corrida é a constraint, também no UPDATE de confirmação. Não há lógica de prazo no
caminho de confirmação: tenta-se o UPDATE e o banco decide. Conflict = outra pessoa ficou com o
horário.
- A confirmação é idempotente nos dois canais: reentrega de webhook e segundo clique respondem o
mesmo sucesso sem duplicar e-mails.
- O webhook (`POST /api/v1/webhooks/whatsapp`) é rota pública barrada por `X-Webhook-Secret`
(comparação em tempo constante), fora do rate limit por IP. Mensagem sem código ou de grupo é
ignorada em silêncio: auto-responder spam é virar spam.
- As quatro variáveis `WHATSAPP_*` andam juntas: com nenhuma, o canal WhatsApp responde 503 e o
resto do sistema vive; com parte delas, o processo não sobe (configuração pela metade é pending
inconfirmável em produção).
- Agendamento criado pelo painel autenticado continua nascendo `confirmed`, sem código nem prazo:
quem marca no balcão é a própria empresa, não há canal a provar.
+7 -4
View File
@@ -80,9 +80,11 @@ Table appointments {
client_id int [not null]
company_id int [not null, note: 'SEM FK para companies (bug #33 do BUGS.md)']
updated_at timestamptz [not null, default: `now()`, note: 'trigger set_updated_at']
status text [not null, default: 'confirmed', note: 'CHECK: confirmed | cancelled_by_client | cancelled_by_company | completed | no_show']
status text [not null, default: 'confirmed', note: 'CHECK: pending | confirmed | cancelled_by_client | cancelled_by_company | completed | no_show | expired']
service_id int [note: 'NULL = encaixe do painel, sem validação de grade']
reminder_sent_at timestamptz [note: 'NULL = lembrete pendente; o UPDATE de remarcação re-arma']
confirmation_code text [unique, note: 'AG-XXXXXX; só agendamento público tem. Quem apresenta confirma (ADR 0004)']
expires_at timestamptz [note: 'prazo do pending (15min WhatsApp, 60min e-mail); vencido vira expired pelo worker']
indexes {
(professional_id, start)
@@ -90,9 +92,10 @@ Table appointments {
Note: '''
A garantia de não-sobreposição é a EXCLUDE constraint appointments_no_overlap
(gist, professional_id WITH =, tstzrange(start,end) WITH &&, WHERE status='confirmed');
ver ADR 0002. Índice parcial appointments_pending_reminder_idx em (start)
WHERE reminder_sent_at IS NULL AND status='confirmed' serve o worker de lembretes.
(gist, professional_id WITH =, tstzrange(start,end) WITH &&,
WHERE status IN ('pending','confirmed')); ver ADRs 0002 e 0004. Índice parcial
appointments_pending_reminder_idx em (start) WHERE reminder_sent_at IS NULL AND
status='confirmed' serve o worker de lembretes.
'''
}
@@ -0,0 +1,33 @@
-- Confirmacao por posse do canal (ver docs/adr/0003): o agendamento publico
-- nasce 'pending' e so vira 'confirmed' quando o cliente prova que o canal e
-- dele (mensagem de WhatsApp com o codigo, ou clique no link do e-mail).
-- 'expired' e o pending cujo prazo venceu: o slot volta para a grade, mas o
-- codigo continua confirmavel enquanto ninguem tomar o horario.
ALTER TABLE "appointments" DROP CONSTRAINT "appointments_status_check";
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_status_check" CHECK (
"status" IN (
'pending',
'confirmed',
'cancelled_by_client',
'cancelled_by_company',
'completed',
'no_show',
'expired'
)
);
-- pending tambem trava o slot: a corrida pelo horario deve acontecer na escolha,
-- quando trocar de horario custa um clique, e nao depois de o cliente ja ter
-- enviado a mensagem. O WHERE daqui precisa continuar espelhado no
-- get_busy_between (mesma regra do ADR 0002).
ALTER TABLE "appointments" DROP CONSTRAINT "appointments_no_overlap";
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_no_overlap" EXCLUDE USING gist (
"professional_id" WITH =,
tstzrange("start", "end") WITH &&
) WHERE ("status" IN ('pending', 'confirmed'));
-- Nullable: agendamento criado pelo painel nao passa por confirmacao e nao tem
-- codigo nem prazo.
ALTER TABLE "appointments" ADD COLUMN "confirmation_code" text UNIQUE;
ALTER TABLE "appointments" ADD COLUMN "expires_at" timestamptz;
+20 -2
View File
@@ -91,7 +91,9 @@ GET {{baseUrl}}/public/possebom/service/1/professionals HTTP/1.1
### Slots públicos
GET {{baseUrl}}/public/possebom/professional/1/service/1/slots/2030-09-01 HTTP/1.1
### Agendamento público (cliente não autentica; recebe magic link por e-mail)
### Agendamento público (cliente não autentica). Nasce PENDING (ADR 0004):
### channel "email" (ou ausente) manda o e-mail com o link de confirmação;
### channel "whatsapp" devolve wa_link para o CLIENTE mandar o código.
POST {{baseUrl}}/public/possebom/appointment HTTP/1.1
content-type: application/json
@@ -101,5 +103,21 @@ content-type: application/json
"start": "2030-09-01T13:00:00Z",
"name": "Cliente Exemplo",
"phone": "41988887777",
"email": "cliente@exemplo.com"
"email": "cliente@exemplo.com",
"channel": "whatsapp"
}
### Confirmação pelo link do e-mail (token de purpose appointment_confirm)
POST {{baseUrl}}/public/appointment/confirm/TOKEN_DO_EMAIL HTTP/1.1
### Webhook da whatsapp-api (fora de /public; secret compartilhado, não JWT)
POST {{baseUrl}}/webhooks/whatsapp HTTP/1.1
content-type: application/json
x-webhook-secret: {{whatsappWebhookSecret}}
{
"message_id": "3EB0EXEMPLO",
"sender_phone": "5541988887777",
"text": "Ola! Quero confirmar meu agendamento. Codigo: AG-XXXXXX",
"is_group": false
}
+23
View File
@@ -30,6 +30,25 @@ pub struct Config {
/// ativa, o processo nao sobe.
#[clap(long, env)]
pub admin_password: Option<String>,
/// Base da whatsapp-api (ex.: http://kauai:8091). As quatro variaveis de
/// WhatsApp sao opcionais em conjunto: sem elas o canal fica desligado
/// (agendar por WhatsApp e o webhook respondem 503) e o resto vive normal.
#[clap(long, env)]
pub whatsapp_api_url: Option<String>,
#[clap(long, env)]
pub whatsapp_api_key: Option<String>,
/// O numero da sessao, com DDI e so digitos (5541999998888): e o destino do
/// link wa.me que o cliente clica para mandar o codigo.
#[clap(long, env)]
pub whatsapp_number: Option<String>,
/// Confere o X-Webhook-Secret dos POSTs que a whatsapp-api entrega em
/// /webhooks/whatsapp.
#[clap(long, env)]
pub whatsapp_webhook_secret: Option<String>,
}
impl Config {
@@ -50,6 +69,10 @@ impl Config {
mail_from: "agenda@localhost".to_string(),
public_base_url: "http://localhost:5173".to_string(),
admin_password: None,
whatsapp_api_url: None,
whatsapp_api_key: None,
whatsapp_number: None,
whatsapp_webhook_secret: None,
}
}
}
+81
View File
@@ -0,0 +1,81 @@
//! O codigo que viaja na mensagem de WhatsApp e confirma o agendamento.
//!
//! Curto porque sobrevive a edicao humana, sem caracteres ambiguos (0/O, 1/I/L)
//! porque gente digita, e aleatorio o bastante (31^6, ~890 milhoes) para nao
//! ser adivinhavel por tentativa com o rate limit publico na frente.
use rand_core::RngCore;
/// Sem 0, O, 1, I e L: 31 simbolos.
const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
const PREFIX: &str = "AG-";
const LEN: usize = 6;
pub fn generate() -> String {
let mut rng = rand_core::OsRng;
let mut out = String::with_capacity(PREFIX.len() + LEN);
out.push_str(PREFIX);
while out.len() < PREFIX.len() + LEN {
// Rejection sampling: 248 = 31 * 8; aceitar 248..=255 enviesaria o
// modulo para o comeco do alfabeto.
let byte = (rng.next_u32() & 0xFF) as u8;
if byte < 248 {
out.push(ALPHABET[(byte % 31) as usize] as char);
}
}
out
}
/// 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> {
let upper = text.to_uppercase();
for (idx, _) in upper.match_indices(PREFIX) {
let rest = &upper.as_bytes()[idx + PREFIX.len()..];
if rest.len() >= LEN && rest[..LEN].iter().all(|b| ALPHABET.contains(b)) {
return Some(format!("{PREFIX}{}", std::str::from_utf8(&rest[..LEN]).expect("o alfabeto e ASCII")));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generates_the_documented_shape() {
for _ in 0..50 {
let code = generate();
assert_eq!(code.len(), 9, "{code}");
assert!(code.starts_with("AG-"));
assert!(code.as_bytes()[3..].iter().all(|b| ALPHABET.contains(b)), "{code}");
// O proprio gerador nunca produz o que extract nao acha.
assert_eq!(extract(&code).as_deref(), Some(code.as_str()));
}
}
#[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"));
}
#[test]
fn is_case_insensitive_because_people_retype() {
assert_eq!(extract("ag-7km3qf").as_deref(), Some("AG-7KM3QF"));
}
#[test]
fn ignores_text_without_a_plausible_code() {
assert_eq!(extract("bom dia, voces atendem sabado?"), None);
// AG- com menos simbolos validos que o tamanho do codigo nao e codigo.
assert_eq!(extract("AG-7KM"), None);
// 0, O e 1 nao existem no alfabeto: um "codigo" com eles e outra coisa.
assert_eq!(extract("AG-0O11II"), None);
}
#[test]
fn a_broken_first_match_does_not_hide_a_valid_second_one() {
assert_eq!(extract("AG-xx AG-7KM3QF").as_deref(), Some("AG-7KM3QF"));
}
}
+1
View File
@@ -7,3 +7,4 @@ pub mod professional;
pub mod public;
pub mod service;
pub mod upload;
pub mod webhook;
+78
View File
@@ -58,6 +58,8 @@ async fn update_appointment_nok_permission() {
let start = future_datetime(10, 0);
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -92,6 +94,7 @@ async fn update_appointment_nok_permission() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -124,6 +127,8 @@ async fn update_appointment_nok_end_start() {
let start = future_datetime(10, 0);
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -161,6 +166,7 @@ async fn update_appointment_nok_end_start() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -195,6 +201,8 @@ async fn update_appointment_nok_past() {
repo_mock_appointment.expect_get_by_id().times(1).returning(|_| {
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -234,6 +242,7 @@ async fn update_appointment_nok_past() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -271,6 +280,8 @@ async fn update_appointment_nok() {
let start = future_datetime(10, 0);
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -295,6 +306,8 @@ async fn update_appointment_nok() {
Ok(vec![
appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -309,6 +322,8 @@ async fn update_appointment_nok() {
},
appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 3,
@@ -344,6 +359,7 @@ async fn update_appointment_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -380,6 +396,8 @@ async fn delete_ok() {
repo_mock_appointment.expect_get_by_id().times(1).returning(|_| {
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -419,6 +437,7 @@ async fn delete_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -464,6 +483,8 @@ async fn delete_nok() {
repo_mock_appointment.expect_get_by_id().times(1).returning(|_| {
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -497,6 +518,7 @@ async fn delete_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -529,6 +551,8 @@ async fn update_appointment_ok() {
repo_mock_appointment.expect_get_by_id().times(1).returning(|_| {
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -567,6 +591,7 @@ async fn update_appointment_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -615,6 +640,8 @@ async fn update_appointment_nok_professional_from_another_company() {
repo_mock_appointment.expect_get_by_id().times(1).returning(|_| {
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -657,6 +684,7 @@ async fn update_appointment_nok_professional_from_another_company() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -720,6 +748,7 @@ async fn get_appointment_by_professional_id_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -781,6 +810,7 @@ async fn get_appointment_by_professional_id_admin_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -838,6 +868,7 @@ async fn get_appointment_by_professional_id_admin_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -897,6 +928,7 @@ async fn get_appointment_by_professional_id_super_admin_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -948,6 +980,7 @@ async fn get_appointment_by_professional_id_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -979,6 +1012,8 @@ async fn get_appointment_by_id_nok_professional() {
repo_mock_appointment.expect_get_by_id().with(eq(1)).return_once(move |_| {
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -1009,6 +1044,7 @@ async fn get_appointment_by_id_nok_professional() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1036,6 +1072,8 @@ async fn get_appointment_by_id_ok() {
repo_mock_appointment.expect_get_by_id().with(eq(1)).return_once(move |_| {
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -1066,6 +1104,7 @@ async fn get_appointment_by_id_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1112,6 +1151,7 @@ async fn get_appointment_by_id_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1134,6 +1174,8 @@ async fn create_appointment_nok_busy() {
repo_mock_appointment.expect_get_busy_between().times(1).returning(move |_, _, _| {
Ok(vec![appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -1170,6 +1212,7 @@ async fn create_appointment_nok_busy() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1230,6 +1273,7 @@ async fn create_appointment_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1293,6 +1337,7 @@ async fn create_appointment_nok_reports_a_real_failure_as_such() {
appointment_repo: Arc::new(repo_mock_appointment) as DynAppointmentRepository,
client_repo: client_repo_of_company(1),
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1341,6 +1386,7 @@ async fn create_appointment_nok_client_from_another_company() {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: client_repo_of_company(2),
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1382,6 +1428,8 @@ async fn update_appointment_marks_a_past_one_as_completed() {
let start = Utc::now() - chrono::Duration::days(1);
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -1417,6 +1465,7 @@ async fn update_appointment_marks_a_past_one_as_completed() {
appointment_repo: Arc::new(repo_mock_appointment) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1440,6 +1489,8 @@ async fn update_appointment_nok_unknown_status() {
let start = Utc::now() + chrono::Duration::days(1);
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -1465,6 +1516,7 @@ async fn update_appointment_nok_unknown_status() {
appointment_repo: Arc::new(repo_mock_appointment) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1509,6 +1561,7 @@ async fn create_appointment_nok_diff_company() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1576,6 +1629,7 @@ async fn create_appointment_nok_admin_diff_company() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1646,6 +1700,7 @@ async fn create_appointment_ok_admin() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1704,6 +1759,7 @@ async fn create_appointment_past() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1765,6 +1821,7 @@ async fn create_appointment_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1823,6 +1880,7 @@ async fn create_appointment_invalid_time_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1862,6 +1920,8 @@ fn grid_update_app(working_hours: Vec<crate::models::working_hours::WorkingHours
repo_mock_appointment.expect_get_by_id().with(eq(1)).return_once(move |_| {
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: Some(3),
id: 1,
@@ -1925,6 +1985,7 @@ fn grid_update_app(working_hours: Vec<crate::models::working_hours::WorkingHours
appointment_repo: Arc::new(repo_mock_appointment) as DynAppointmentRepository,
client_repo: Arc::new(client_mock) as DynClientRepository,
mailer: Arc::new(mailer_mock) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
routes().with_state(shared_state)
@@ -1966,6 +2027,8 @@ fn service_swap_app(service_company: i32, service_active: bool, offered: bool) -
repo_mock_appointment.expect_get_by_id().with(eq(1)).return_once(move |_| {
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: Some(3),
id: 1,
@@ -2008,6 +2071,7 @@ fn service_swap_app(service_company: i32, service_active: bool, offered: bool) -
appointment_repo: Arc::new(repo_mock_appointment) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
routes().with_state(shared_state)
@@ -2070,6 +2134,8 @@ fn professional_swap_app(target_active: bool, offers: bool) -> axum::Router {
repo_mock_appointment.expect_get_by_id().with(eq(1)).return_once(move |_| {
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: Some(3),
id: 1,
@@ -2106,6 +2172,7 @@ fn professional_swap_app(target_active: bool, offers: bool) -> axum::Router {
appointment_repo: Arc::new(repo_mock_appointment) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
routes().with_state(shared_state)
@@ -2157,6 +2224,8 @@ async fn update_detaches_the_service_with_an_explicit_null() {
repo_mock_appointment.expect_get_by_id().with(eq(1)).return_once(move |_| {
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: Some(3),
id: 1,
@@ -2189,6 +2258,7 @@ async fn update_detaches_the_service_with_an_explicit_null() {
appointment_repo: Arc::new(repo_mock_appointment) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2213,6 +2283,8 @@ async fn update_nok_moving_the_end_into_the_past() {
repo_mock_appointment.expect_get_by_id().with(eq(1)).return_once(move |_| {
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
service_id: None,
id: 1,
@@ -2239,6 +2311,7 @@ async fn update_nok_moving_the_end_into_the_past() {
appointment_repo: Arc::new(repo_mock_appointment) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2273,6 +2346,7 @@ async fn create_appointment_nok_with_a_deactivated_professional() {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2392,6 +2466,7 @@ async fn create_with_a_service_refuses_an_end_that_ignores_the_duration() {
appointment_repo: Arc::new(repo_mock_appointment) as DynAppointmentRepository,
client_repo: client_repo_of_company(1),
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2455,6 +2530,8 @@ async fn a_reschedule_emails_the_client_a_fresh_link() {
repo_mock_appointment.expect_get_by_id().with(eq(1)).return_once(move |_| {
Ok(appointment::Appointment {
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
status: "confirmed".to_string(),
// Encaixe de proposito: pula duracao e grade, o alvo e o aviso.
service_id: None,
@@ -2500,6 +2577,7 @@ async fn a_reschedule_emails_the_client_a_fresh_link() {
appointment_repo: Arc::new(repo_mock_appointment) as DynAppointmentRepository,
client_repo: Arc::new(client_mock) as DynClientRepository,
mailer: Arc::new(mailer_mock) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
+15
View File
@@ -153,6 +153,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -195,6 +196,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -243,6 +245,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -288,6 +291,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -346,6 +350,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -396,6 +401,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -440,6 +446,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -487,6 +494,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -531,6 +539,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -563,6 +572,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -596,6 +606,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -637,6 +648,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -676,6 +688,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -718,6 +731,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
@@ -755,6 +769,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().merge(auth_routes()).with_state(shared_state);
+3
View File
@@ -329,6 +329,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
routes().with_state(shared_state)
@@ -358,6 +359,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -486,6 +488,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
+24
View File
@@ -63,6 +63,7 @@ async fn create_client_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -127,6 +128,7 @@ async fn create_client_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
@@ -192,6 +194,7 @@ async fn get_client_by_id_no_permission() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
@@ -248,6 +251,7 @@ async fn get_client_by_id_as_admin() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
@@ -305,6 +309,7 @@ async fn get_client_by_id_as_unprivileged() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
@@ -365,6 +370,7 @@ async fn get_client_by_phone_no_permission() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
@@ -424,6 +430,7 @@ async fn get_client_by_phone_as_admin() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
@@ -484,6 +491,7 @@ async fn get_client_by_phone_as_unprivileged() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
@@ -535,6 +543,7 @@ async fn delete_nok_still_referenced() {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(repo_mock_client) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -590,6 +599,7 @@ async fn delete_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
@@ -648,6 +658,7 @@ async fn delete_as_admin_diff_company() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
@@ -714,6 +725,7 @@ async fn update_client_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -773,6 +785,7 @@ async fn create_client_normalizes_a_formatted_phone() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -824,6 +837,7 @@ async fn create_client_nok_with_an_oversized_email() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -889,6 +903,7 @@ async fn update_client_normalizes_a_formatted_phone() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -941,6 +956,7 @@ async fn update_client_nok_with_an_oversized_email() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -991,6 +1007,7 @@ async fn update_client_with_null_email_clears_it() {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(repo_mock_client) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1061,6 +1078,7 @@ async fn update_client_ok_unprivileged() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1134,6 +1152,7 @@ async fn update_client_nok_admin() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1198,6 +1217,7 @@ async fn get_all_clients_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
@@ -1257,6 +1277,7 @@ async fn get_all_clients_ok_super_admin() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
@@ -1316,6 +1337,7 @@ async fn get_clients_ok_admin_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
@@ -1375,6 +1397,7 @@ async fn get_all_clients_ok_admin() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
@@ -1420,6 +1443,7 @@ async fn get_all_clients_ok_admin_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
+21
View File
@@ -167,6 +167,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -209,6 +210,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -243,6 +245,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -281,6 +284,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -315,6 +319,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -347,6 +352,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -385,6 +391,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -424,6 +431,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -458,6 +466,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -490,6 +499,7 @@ mod tests {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -533,6 +543,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -583,6 +594,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -643,6 +655,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -697,6 +710,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -752,6 +766,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -813,6 +828,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -876,6 +892,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -939,6 +956,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1002,6 +1020,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1062,6 +1081,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1119,6 +1139,7 @@ mod tests {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
+47
View File
@@ -59,6 +59,7 @@ async fn create_professional_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -121,6 +122,7 @@ async fn create_professional_super_admin_unauthorized() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -181,6 +183,7 @@ async fn create_professional_admin_for_diff_company_unauthorized() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -247,6 +250,7 @@ async fn create_professional_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -304,6 +308,7 @@ async fn create_professional_normalizes_a_formatted_phone() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -353,6 +358,7 @@ async fn create_professional_nok_with_a_phone_that_is_not_a_phone() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -412,6 +418,7 @@ async fn create_professional_super_admin_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -476,6 +483,7 @@ async fn create_professional_admin_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -540,6 +548,7 @@ async fn create_professional_super_admin_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -604,6 +613,7 @@ async fn create_professional_admin_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -668,6 +678,7 @@ async fn create_professional_admin_empty_pass() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -716,6 +727,7 @@ async fn a_deactivated_professional_is_refused_with_a_valid_token() {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -746,6 +758,7 @@ async fn a_deleted_professional_gets_401_not_500() {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -788,6 +801,7 @@ async fn test_logged_profile() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -833,6 +847,7 @@ async fn get_professional_by_email() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -889,6 +904,7 @@ async fn get_professional_by_email_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -938,6 +954,7 @@ async fn get_professionals_by_company_id_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -995,6 +1012,7 @@ async fn get_professionals_by_company_id_nok_infra_is_a_500() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1029,6 +1047,7 @@ async fn create_professional_only_calls_a_conflict_already_exists() {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1091,6 +1110,7 @@ async fn get_professionals_by_company_id_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1140,6 +1160,7 @@ async fn delete_professional_by_id_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1186,6 +1207,7 @@ async fn delete_professional_by_id_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1237,6 +1259,7 @@ async fn update_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1301,6 +1324,7 @@ async fn update_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1365,6 +1389,7 @@ async fn update_nok_invalid_input() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1422,6 +1447,7 @@ async fn update_ok_professional() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1476,6 +1502,7 @@ async fn update_ok_professional_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1535,6 +1562,7 @@ async fn update_ok_professional_nok_email() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1592,6 +1620,7 @@ async fn update_email_race_reports_the_conflict_not_a_missing_professional() {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1648,6 +1677,7 @@ async fn update_professional_ignores_an_avatar_in_the_body() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1694,6 +1724,7 @@ async fn create_professional_stores_the_email_lowercased() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1754,6 +1785,7 @@ async fn update_professional_stores_the_email_lowercased() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1801,6 +1833,7 @@ async fn update_professional_to_a_missing_company_answers_400() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1853,6 +1886,7 @@ async fn update_with_an_empty_password_keeps_the_current_one() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1904,6 +1938,7 @@ async fn update_with_an_empty_email_keeps_the_login_email() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -1952,6 +1987,7 @@ async fn update_with_an_empty_role_keeps_the_current_role() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2000,6 +2036,7 @@ async fn update_with_empty_name_and_phone_keeps_them() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2048,6 +2085,7 @@ async fn get_all_professional_super_admin_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2098,6 +2136,7 @@ async fn get_all_professional_admin_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2140,6 +2179,7 @@ async fn get_all_professional_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2187,6 +2227,7 @@ async fn get_by_id_super_admin_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2237,6 +2278,7 @@ async fn get_by_id_admin_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2288,6 +2330,7 @@ async fn get_by_id_admin_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2339,6 +2382,7 @@ async fn get_by_id_professional_of_another_company_nok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2385,6 +2429,7 @@ async fn get_by_id_professional_ok() {
appointment_repo,
client_repo,
mailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2421,6 +2466,7 @@ async fn get_by_id_a_colleague_of_the_same_company_is_readable() {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
@@ -2452,6 +2498,7 @@ async fn create_professional_without_company_id_is_a_bad_request() {
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
let app = routes().with_state(shared_state);
+394 -35
View File
@@ -12,10 +12,12 @@ use axum::{
Json,
};
use chrono::NaiveDate;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::{
confirmation_code,
controllers::availability::{company_local_date, compute_slots},
error::{AppError, DataAccessError},
magic_link,
@@ -93,6 +95,40 @@ pub struct PublicBooking {
pub professional_id: i32,
pub service_id: i32,
pub start: chrono::DateTime<chrono::Utc>,
/// "whatsapp" ou "email" (ausente = email): por onde o cliente vai provar
/// que o canal e dele. O agendamento nasce pending nos dois casos.
#[serde(default)]
pub channel: Option<String>,
}
/// Por onde a prova de posse chega. Decide o prazo do pending e o que a
/// resposta do create carrega.
#[derive(PartialEq, Clone, Copy)]
enum BookingChannel {
WhatsApp,
Email,
}
impl BookingChannel {
/// Quanto tempo o slot fica travado esperando a prova. WhatsApp e clicar e
/// enviar; e-mail a pessoa pode so abrir mais tarde. Vencido o prazo o slot
/// volta a grade, mas o codigo segue confirmavel enquanto ninguem tomar o
/// horario, entao os numeros nao sao criticos.
fn confirm_ttl(self) -> chrono::Duration {
match self {
BookingChannel::WhatsApp => chrono::Duration::minutes(15),
BookingChannel::Email => chrono::Duration::minutes(60),
}
}
}
/// O link que o cliente clica: abre o WhatsApp dele com a mensagem pronta. E o
/// CLIENTE quem envia, de proposito: conversa iniciada por ele e o que mantem o
/// nosso numero longe de bloqueio por spam, alem de provar que o telefone e
/// dele. So o codigo precisa sobreviver a edicao da mensagem.
fn wa_link(number: &str, code: &str) -> String {
let text = format!("Ola! Quero confirmar meu agendamento. Codigo: {code}");
format!("https://wa.me/{number}?text={}", utf8_percent_encode(&text, NON_ALPHANUMERIC))
}
async fn company_by_slug(app: &Arc<AppState>, slug: &str) -> Result<Company, AppError> {
@@ -203,6 +239,19 @@ pub async fn create_appointment(
return Err(AppError::BadRequest("email must have at most 70 characters".to_string()));
}
let channel = match booking.channel.as_deref() {
// Ausente e e-mail: o contrato de quem nao manda o campo continua
// valendo, so que o horario agora nasce pending e pede o clique.
None | Some("email") => BookingChannel::Email,
Some("whatsapp") => BookingChannel::WhatsApp,
Some(other) => return Err(AppError::BadRequest(format!("unknown channel '{other}'"))),
};
// Barra ANTES de criar: um pending sem caminho de confirmacao so travaria o
// slot ate expirar, sem nunca poder virar horario.
if channel == BookingChannel::WhatsApp && app.config.whatsapp_number.is_none() {
return Err(AppError::ChannelUnavailable);
}
let professional = app
.professional_repo
.get_professional_by_id(booking.professional_id)
@@ -232,17 +281,23 @@ pub async fn create_appointment(
let client_id = find_or_create_client(&app, company.id, &booking, &phone).await?;
let end = booking.start + chrono::Duration::minutes(service.duration_minutes as i64);
let code = confirmation_code::generate();
let expires_at = chrono::Utc::now() + channel.confirm_ttl();
let appointment_id = app
.appointment_repo
.create_appointment(NewAppointment {
start: booking.start,
end,
client_id,
description: None,
professional_id: booking.professional_id,
company_id: company.id,
service_id: Some(booking.service_id),
})
.create_pending(
NewAppointment {
start: booking.start,
end,
client_id,
description: None,
professional_id: booking.professional_id,
company_id: company.id,
service_id: Some(booking.service_id),
},
code.clone(),
expires_at,
)
.await
// O caso comum e a corrida pelo mesmo horario, que a constraint decide.
// Qualquer outra falha respondia a mesma coisa, e o cliente final via
@@ -253,33 +308,148 @@ pub async fn create_appointment(
})?;
let token = magic_link::sign(appointment_id, end)?;
let link = format!("{}/agendamento/{}", app.config.public_base_url.trim_end_matches('/'), token);
// Os e-mails saem depois de o horario ja estar gravado, em background:
// relay lento nao atrasa a resposta, e falha de envio nao desfaz nada. O
// cliente sempre tem o link na resposta como rede.
if let Ok(appointment) = app.appointment_repo.get_by_id(appointment_id).await {
if let Ok(client) = app.client_repo.get_by_id(client_id).await {
crate::notify::send_in_background(
app.mailer.clone(),
crate::notify::confirmation(&company, &client, &service, &professional, &appointment, &link),
);
crate::notify::send_in_background(
app.mailer.clone(),
crate::notify::professional_booked(&company, &professional, &client, &service, &appointment),
);
// Confirmacao para o cliente e aviso ao profissional NAO saem aqui: saem em
// after_confirmation, quando a prova do canal chegar. Antes disso o
// compromisso nao existe, e avisar o profissional de horario que pode nunca
// confirmar e ruido.
let wa_link = match channel {
BookingChannel::WhatsApp => {
// O unwrap_or_default nunca acontece: canal WhatsApp sem numero foi
// barrado antes de criar qualquer coisa.
Some(wa_link(app.config.whatsapp_number.as_deref().unwrap_or_default(), &code))
}
}
BookingChannel::Email => {
let confirm_token = magic_link::sign_confirmation(appointment_id, booking.start)?;
let confirm_link = format!("{}/confirmar/{}", app.config.public_base_url.trim_end_matches('/'), confirm_token);
if let Ok(appointment) = app.appointment_repo.get_by_id(appointment_id).await {
if let Ok(client) = app.client_repo.get_by_id(client_id).await {
crate::notify::send_in_background(
app.mailer.clone(),
crate::notify::confirm_request(&company, &client, &service, &professional, &appointment, &confirm_link),
);
}
}
None
}
};
// A pagina fica com o access_token e faz polling do GET /appointment ate o
// status virar confirmed (WhatsApp) ou mostra "confira seu e-mail".
Ok(Json(json!({
"message": "Appointment created successfully",
"message": "Appointment created, waiting for confirmation",
"id": appointment_id,
"start": booking.start,
"end": end,
"status": AppointmentStatus::Pending.as_str(),
"expires_at": expires_at,
"access_token": token,
"wa_link": wa_link,
})))
}
/// O clique no link do e-mail "confirme seu horario". Idempotente: o segundo
/// clique responde o mesmo sucesso e nao reenvia nada.
pub async fn confirm_appointment(State(app): State<Arc<AppState>>, Path(token): Path<String>) -> Result<Json<Value>, AppError> {
let id = magic_link::verify_confirmation(&token)?;
match app.appointment_repo.confirm_by_id(id).await {
Ok(appointment) => {
let response = confirmed_json(&appointment);
spawn_after_confirmation(app, appointment, None, true);
Ok(response)
}
Err(DataAccessError::NotFound) => {
// Fora de pending/expired: ou ja confirmado (segundo clique), ou
// cancelado no meio do caminho.
let appointment = app.appointment_repo.get_by_id(id).await.map_err(|e| e.not_found_as(AppError::NotFound))?;
if appointment.status == AppointmentStatus::Confirmed.as_str() {
return Ok(confirmed_json(&appointment));
}
Err(AppError::BadRequest("this appointment can no longer be confirmed".to_string()))
}
// O pending expirou e outra pessoa ficou com o horario: a constraint
// arbitrou a corrida.
Err(DataAccessError::Conflict) => Err(AppError::AppointmentAlreadyExists),
Err(_) => Err(AppError::InternalServerError),
}
}
fn confirmed_json(appointment: &Appointment) -> Json<Value> {
Json(json!({
"message": "Appointment confirmed",
"id": appointment.id,
"start": appointment.start,
"end": appointment.end,
"status": appointment.status,
}))
}
/// Dispara o pos-confirmacao em background: quem confirma nao espera busca nem
/// envio nenhum.
pub(crate) fn spawn_after_confirmation(app: Arc<AppState>, appointment: Appointment, reply_to: Option<String>, send_emails: bool) {
tokio::spawn(after_confirmation(app, appointment, reply_to, send_emails));
}
/// Tudo que acontece DEPOIS da prova do canal, best-effort: os e-mails que a
/// criacao deixou de mandar (confirmacao ao cliente, aviso ao profissional) e,
/// no caminho WhatsApp, a resposta na conversa que o cliente abriu.
///
/// `send_emails: false` e a reentrega idempotente do webhook: a resposta no
/// WhatsApp sai de novo, os e-mails nao duplicam.
async fn after_confirmation(app: Arc<AppState>, appointment: Appointment, reply_to: Option<String>, send_emails: bool) {
let Ok(company) = app.company_repo.get_company_by_id(appointment.company_id).await else {
tracing::warn!(
"confirmacao {}: empresa {} nao carregou, avisos nao sairam",
appointment.id,
appointment.company_id
);
return;
};
let service = match appointment.service_id {
Some(id) => app.service_repo.get_by_id(id).await.ok(),
None => None,
};
let Ok(token) = magic_link::sign(appointment.id, appointment.end) else {
tracing::warn!("confirmacao {}: nao assinei o magic link, avisos nao sairam", appointment.id);
return;
};
let link = format!("{}/agendamento/{}", app.config.public_base_url.trim_end_matches('/'), token);
if let Some(to) = reply_to {
crate::whatsapp::send_in_background(
app.whatsapp.clone(),
to,
crate::notify::whatsapp_confirmed(&company, service.as_ref(), &appointment, &link),
);
}
if !send_emails {
return;
}
let Ok(client) = app.client_repo.get_by_id(appointment.client_id).await else {
tracing::warn!(
"confirmacao {}: cliente {} nao carregou, e-mails nao sairam",
appointment.id,
appointment.client_id
);
return;
};
let Ok(professional) = app.professional_repo.get_professional_by_id(appointment.professional_id).await else {
tracing::warn!("confirmacao {}: profissional nao carregou, e-mails nao sairam", appointment.id);
return;
};
// Agendamento publico sempre tem servico; sem ele nao ha o que descrever.
let Some(service) = service else { return };
crate::notify::send_in_background(
app.mailer.clone(),
crate::notify::confirmation(&company, &client, &service, &professional, &appointment, &link),
);
crate::notify::send_in_background(
app.mailer.clone(),
crate::notify::professional_booked(&company, &professional, &client, &service, &appointment),
);
}
/// O par (telefone, empresa) e a identidade do Client. Reusa quando ja existe.
///
/// Nao sobrescreve o nome de proposito: a recepcao pode ter cadastrado o nome
@@ -510,6 +680,8 @@ mod tests {
status: "confirmed".to_string(),
service_id: Some(10),
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
}
}
@@ -581,12 +753,19 @@ mod tests {
}
fn into_app(self) -> axum::Router {
self.into_app_with_config(Config::empty())
}
/// Config custom: os testes de canal WhatsApp precisam do numero (e só
/// dele) configurado.
fn into_app_with_config(self, config: Config) -> axum::Router {
// O envio roda em task de background: sem expectativa fixa, so nao
// pode explodir se acontecer.
let mut mailer = MockMailer::new();
mailer.expect_send().returning(|_| Ok(()));
let state = Arc::new(AppState {
config: Config::empty(),
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config,
company_repo: Arc::new(self.company) as DynCompanyRepository,
professional_repo: Arc::new(self.professional) as DynProfessionalRepository,
appointment_repo: Arc::new(self.appointment) as DynAppointmentRepository,
@@ -615,6 +794,14 @@ mod tests {
Request::post(uri).header("x-forwarded-for", TEST_IP).json(body)
}
/// POST sem corpo: a confirmacao por token nao recebe JSON nenhum.
fn public_post_empty(uri: &str) -> Request<Body> {
Request::post(uri)
.header("x-forwarded-for", TEST_IP)
.body(Body::empty())
.expect("request valido")
}
fn public_delete(uri: &str) -> Request<Body> {
Request::delete(uri)
.header("x-forwarded-for", TEST_IP)
@@ -715,17 +902,19 @@ mod tests {
.return_once(|_| Ok(55));
m.client.expect_get_by_id().with(eq(55)).returning(|_| Ok(client_fixture()));
m.appointment
.expect_create_appointment()
.withf(move |a| {
.expect_create_pending()
.withf(move |a, code, expires| {
a.company_id == 1
&& a.service_id == Some(10)
&& a.professional_id == 7
&& a.client_id == 55
&& a.start == start
&& a.end == start + chrono::Duration::minutes(30)
&& code.starts_with("AG-")
&& *expires > Utc::now()
})
.times(1)
.return_once(|_| Ok(99));
.return_once(|_, _, _| Ok(99));
m.appointment.expect_get_by_id().with(eq(99)).returning(move |_| Ok(appointment_fixture(start)));
let response = m.into_app().oneshot(public_post("/possebom/appointment", booking_body(start))).await.unwrap();
@@ -751,10 +940,10 @@ mod tests {
.returning(|_, _| Ok(client_fixture()));
m.client.expect_get_by_id().with(eq(55)).returning(|_| Ok(client_fixture()));
m.appointment
.expect_create_appointment()
.withf(|a| a.client_id == 55)
.expect_create_pending()
.withf(|a, _, _| a.client_id == 55)
.times(1)
.return_once(|_| Ok(99));
.return_once(|_, _, _| Ok(99));
m.appointment.expect_get_by_id().with(eq(99)).returning(move |_| Ok(appointment_fixture(start)));
let response = m.into_app().oneshot(public_post("/possebom/appointment", booking_body(start))).await.unwrap();
@@ -814,10 +1003,10 @@ mod tests {
.return_once(|_, _| Ok(client_fixture()));
m.client.expect_get_by_id().with(eq(55)).returning(|_| Ok(client_fixture()));
m.appointment
.expect_create_appointment()
.withf(|a| a.client_id == 55)
.expect_create_pending()
.withf(|a, _, _| a.client_id == 55)
.times(1)
.return_once(|_| Ok(99));
.return_once(|_, _, _| Ok(99));
m.appointment.expect_get_by_id().with(eq(99)).returning(move |_| Ok(appointment_fixture(start)));
let response = m.into_app().oneshot(public_post("/possebom/appointment", booking_body(start))).await.unwrap();
@@ -827,6 +1016,77 @@ mod tests {
assert_eq!(body["id"], 99);
}
/// O canal WhatsApp inverte quem envia: a resposta traz o link wa.me com o
/// MESMO codigo gravado no pending, e o servidor nao manda nada na criacao.
#[tokio::test]
async fn a_whatsapp_booking_returns_the_wa_link_with_the_stored_code() {
let start = slot_start();
let mut m = Mocks::with_open_grid();
m.client
.expect_get_by_phone()
.with(eq(1), eq("41999998888"))
.returning(|_, _| Ok(client_fixture()));
let stored = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let sink = stored.clone();
m.appointment
.expect_create_pending()
.withf(|a, code, _| a.client_id == 55 && code.starts_with("AG-"))
.times(1)
.return_once(move |_, code, _| {
*sink.lock().expect("guardar o codigo visto") = code;
Ok(99)
});
let mut body = booking_body(start);
body["channel"] = json!("whatsapp");
let mut config = Config::empty();
config.whatsapp_number = Some("5541999990000".to_string());
let response = m
.into_app_with_config(config)
.oneshot(public_post("/possebom/appointment", body))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["status"], "pending");
let wa = body["wa_link"].as_str().expect("resposta traz o wa_link");
assert!(wa.starts_with("https://wa.me/5541999990000?text="), "{wa}");
// NON_ALPHANUMERIC encoda o hifen (%2D); o resto do codigo e
// alfanumerico e sobrevive como esta no texto do link.
let code = stored.lock().expect("ler o codigo visto").clone();
assert!(wa.contains(&code.replace('-', "%2D")), "codigo {code} fora do link {wa}");
}
/// Sem WHATSAPP_NUMBER o canal nao existe neste deploy: 503 antes de criar
/// qualquer coisa (os mocks ficam sem expectativa de escrita de proposito).
#[tokio::test]
async fn a_whatsapp_booking_without_the_channel_configured_is_refused() {
let start = slot_start();
let m = Mocks::with_open_grid();
let mut body = booking_body(start);
body["channel"] = json!("whatsapp");
let response = m.into_app().oneshot(public_post("/possebom/appointment", body)).await.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
/// Canal desconhecido e 400, nao um default silencioso: "sms" pode ser um
/// canal real um dia, e ate la nao pode virar e-mail calado.
#[tokio::test]
async fn an_unknown_channel_is_refused() {
let start = slot_start();
let m = Mocks::with_open_grid();
let mut body = booking_body(start);
body["channel"] = json!("sms");
let response = m.into_app().oneshot(public_post("/possebom/appointment", body)).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
/// A outra metade do 78: infra falhando na BUSCA nao significa "cliente
/// nao existe". Cair no ramo de criacao mascarava o erro real; sem
/// expectativa de create, o mock derruba o teste se o handler criar.
@@ -867,6 +1127,105 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
// -- confirmacao ------------------------------------------------------
/// O clique no link do e-mail: pending vira confirmed e a resposta ja diz o
/// status novo. O pos-confirmacao (e-mails, resposta) roda em background e
/// nao faz parte do contrato HTTP.
#[tokio::test]
async fn clicking_the_email_link_confirms_a_pending_appointment() {
let start = slot_start();
let mut m = Mocks::with_open_grid();
m.client.expect_get_by_id().returning(|_| Ok(client_fixture()));
m.appointment
.expect_confirm_by_id()
.with(eq(99))
.times(1)
.return_once(move |_| Ok(appointment_fixture(start)));
let token = magic_link::sign_confirmation(99, start).unwrap();
let response = m.into_app().oneshot(public_post_empty(&format!("/appointment/confirm/{token}"))).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["id"], 99);
assert_eq!(body["status"], "confirmed");
}
/// Segundo clique responde o mesmo sucesso: e-mail reaberto nao pode virar
/// erro nem reenviar aviso nenhum.
#[tokio::test]
async fn a_second_click_answers_the_same_success() {
let start = slot_start();
let mut m = Mocks::default();
m.appointment
.expect_confirm_by_id()
.with(eq(99))
.return_once(|_| Err(DataAccessError::NotFound));
m.appointment.expect_get_by_id().with(eq(99)).returning(move |_| Ok(appointment_fixture(start)));
let token = magic_link::sign_confirmation(99, start).unwrap();
let response = m.into_app().oneshot(public_post_empty(&format!("/appointment/confirm/{token}"))).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["status"], "confirmed");
}
/// O pending expirou e outra pessoa ficou com o horario: a constraint
/// arbitrou, e quem clicou tarde recebe o conflito, nao um 500.
#[tokio::test]
async fn a_confirmation_that_lost_the_slot_race_is_refused() {
let start = slot_start();
let mut m = Mocks::default();
m.appointment
.expect_confirm_by_id()
.with(eq(99))
.return_once(|_| Err(DataAccessError::Conflict));
let token = magic_link::sign_confirmation(99, start).unwrap();
let response = m.into_app().oneshot(public_post_empty(&format!("/appointment/confirm/{token}"))).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
/// Cancelado nao volta a vida por um clique atrasado no e-mail.
#[tokio::test]
async fn a_cancelled_appointment_cannot_be_confirmed() {
let start = slot_start();
let mut m = Mocks::default();
m.appointment
.expect_confirm_by_id()
.with(eq(99))
.return_once(|_| Err(DataAccessError::NotFound));
m.appointment.expect_get_by_id().with(eq(99)).returning(move |_| {
let mut a = appointment_fixture(start);
a.status = "cancelled_by_client".to_string();
Ok(a)
});
let token = magic_link::sign_confirmation(99, start).unwrap();
let response = m.into_app().oneshot(public_post_empty(&format!("/appointment/confirm/{token}"))).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
/// A barreira de proposito: o magic link de ACESSO nao confirma horario.
/// Sem expectativa nenhuma no repositorio: a recusa vem antes do banco.
#[tokio::test]
async fn an_access_token_cannot_confirm() {
let start = slot_start();
let token = magic_link::sign(99, start).unwrap();
let response = Mocks::default()
.into_app()
.oneshot(public_post_empty(&format!("/appointment/confirm/{token}")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
// -- magic link -------------------------------------------------------
#[tokio::test]
+1
View File
@@ -170,6 +170,7 @@ mod tests {
fn app(service_mock: MockServiceRepository, mut professional_mock: MockProfessionalRepository, caller: SessionState) -> axum::Router {
professional_mock.expect_update_last_activity().return_once(move |_| Ok(caller));
let shared_state = Arc::new(AppState {
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
company_repo: Arc::new(MockCompanyRepository::new()) as DynCompanyRepository,
professional_repo: Arc::new(professional_mock) as DynProfessionalRepository,
+1
View File
@@ -104,6 +104,7 @@ fn app(mut professional_mock: MockProfessionalRepository, caller: SessionState,
service_repo: Arc::new(MockServiceRepository::new()) as DynServiceRepository,
availability_repo: Arc::new(MockAvailabilityRepository::new()) as DynAvailabilityRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
});
routes().with_state(shared_state)
}
+355
View File
@@ -0,0 +1,355 @@
//! O caminho de volta do WhatsApp.
//!
//! A whatsapp-api entrega aqui cada mensagem que a sessao recebe; a que trouxer
//! um codigo de confirmacao confirma o agendamento e recebe a resposta na
//! propria conversa. Rota publica de proposito (quem chama e um servico, sem
//! JWT); a barreira e o X-Webhook-Secret, comparado em tempo constante.
//!
//! Mensagem processada OU ignorada responde 200: o retry da whatsapp-api existe
//! para falha de infra, nao para mensagem que nao interessa.
use std::sync::Arc;
use axum::{extract::State, http::HeaderMap, Json};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
confirmation_code,
error::{AppError, DataAccessError},
models::appointment::AppointmentStatus,
AppState,
};
/// O payload do webhook da whatsapp-api. So o que usamos: serde ignora o resto
/// (message_id, chat_jid, sender_jid, timestamp).
#[derive(Deserialize)]
pub struct IncomingMessage {
pub sender_phone: String,
/// Vazio quando a mensagem e so midia.
#[serde(default)]
pub text: String,
#[serde(default)]
pub is_group: bool,
}
/// Compara sem curto-circuito: o tempo da resposta nao pode contar quantos
/// bytes bateram. O comprimento ainda vaza, e tudo bem: segredo nao se adivinha
/// por tamanho.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
pub async fn whatsapp(State(app): State<Arc<AppState>>, headers: HeaderMap, Json(message): Json<IncomingMessage>) -> Result<Json<Value>, AppError> {
let Some(secret) = app.config.whatsapp_webhook_secret.as_deref() else {
return Err(AppError::ChannelUnavailable);
};
let presented = headers.get("x-webhook-secret").and_then(|v| v.to_str().ok()).unwrap_or("");
if !constant_time_eq(presented.as_bytes(), secret.as_bytes()) {
return Err(AppError::WrongCredential);
}
// Grupo nao agenda, e mensagem sem codigo e conversa normal do numero: os
// dois casos saem calados, sem auto-resposta (responder spam e virar spam).
if message.is_group {
return Ok(Json(json!({ "status": "ignored" })));
}
let Some(code) = confirmation_code::extract(&message.text) else {
return Ok(Json(json!({ "status": "ignored" })));
};
match app.appointment_repo.confirm_by_code(code.clone()).await {
Ok(appointment) => {
super::public::spawn_after_confirmation(app, appointment, Some(message.sender_phone), true);
Ok(Json(json!({ "status": "confirmed" })))
}
Err(DataAccessError::NotFound) => {
// Codigo com cara de codigo, mas fora de pending/expired: ou e a
// reentrega de uma mensagem ja processada (responde o sucesso de
// novo, sem duplicar os e-mails), ou o codigo nunca existiu / o
// agendamento ja era.
match app.appointment_repo.get_by_confirmation_code(code).await {
Ok(a) if a.status == AppointmentStatus::Confirmed.as_str() => {
super::public::spawn_after_confirmation(app, a, Some(message.sender_phone), false);
Ok(Json(json!({ "status": "confirmed" })))
}
_ => {
crate::whatsapp::send_in_background(
app.whatsapp.clone(),
message.sender_phone,
"Nao encontrei um agendamento aguardando confirmacao para esse codigo. Se precisar, agende novamente pelo site.".to_string(),
);
Ok(Json(json!({ "status": "not_found" })))
}
}
}
Err(DataAccessError::Conflict) => {
// A corrida foi perdida: o pending expirou e outra pessoa ficou com
// o horario. A resposta explica na conversa; o slot, quem manda e o
// banco.
crate::whatsapp::send_in_background(
app.whatsapp.clone(),
message.sender_phone,
"Esse horario acabou de ser ocupado por outra pessoa. Agende novamente pelo site, por favor.".to_string(),
);
Ok(Json(json!({ "status": "conflict" })))
}
// Falha de infra: 500, e o retry da whatsapp-api entrega de novo.
Err(_) => Err(AppError::InternalServerError),
}
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
routing::post,
Router,
};
use chrono::Utc;
use mockall::predicate::eq;
use tower::ServiceExt;
use super::*;
use crate::{
config::Config,
mailer::{DynMailer, MockMailer},
models::{appointment::Appointment, company::Company},
repositories::{
appointment::{DynAppointmentRepository, MockAppointmentRepository},
availability::{DynAvailabilityRepository, MockAvailabilityRepository},
client::{DynClientRepository, MockClientRepository},
company::{DynCompanyRepository, MockCompanyRepository},
professional::{DynProfessionalRepository, MockProfessionalRepository},
service::{DynServiceRepository, MockServiceRepository},
},
tests::utils::{response_json, RequestBuilderExt},
whatsapp::{DynWhatsApp, MockWhatsApp},
AppState,
};
#[test]
fn constant_time_eq_compares_content_not_prefix() {
assert!(constant_time_eq(b"segredo", b"segredo"));
assert!(!constant_time_eq(b"segredo", b"segredo2"));
assert!(!constant_time_eq(b"segredo", b"segrado"));
assert!(!constant_time_eq(b"", b"segredo"));
assert!(constant_time_eq(b"", b""));
}
// -- fixtures e montagem ----------------------------------------------
const SECRET: &str = "segredo-teste";
fn confirmed_fixture() -> Appointment {
let start = Utc::now() + chrono::Duration::days(30);
Appointment {
id: 99,
created_at: Utc::now(),
updated_at: Utc::now(),
start,
end: start + chrono::Duration::minutes(30),
client_id: 55,
description: None,
professional_id: 7,
company_id: 1,
status: "confirmed".to_string(),
service_id: Some(10),
reminder_sent_at: None,
confirmation_code: Some("AG-7KM3QF".to_string()),
expires_at: Some(Utc::now()),
}
}
fn webhook_config() -> Config {
let mut c = Config::empty();
c.whatsapp_webhook_secret = Some(SECRET.to_string());
c
}
/// So o repositorio de agendamento importa aqui: o resto do pos-confirmacao
/// roda em background, best-effort, e ganha mocks permissivos para nao
/// explodir se a task chegar a rodar dentro do teste.
fn app(appointment: MockAppointmentRepository, config: Config) -> Router {
let mut company = MockCompanyRepository::new();
company.expect_get_company_by_id().returning(|_| {
Ok(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,
created_at: Utc::now(),
})
});
let mut mailer = MockMailer::new();
mailer.expect_send().returning(|_| Ok(()));
let mut whatsapp_mock = MockWhatsApp::new();
whatsapp_mock.expect_send_text().returning(|_, _| Ok(()));
let state = Arc::new(AppState {
config,
company_repo: Arc::new(company) as DynCompanyRepository,
professional_repo: Arc::new(MockProfessionalRepository::new()) as DynProfessionalRepository,
appointment_repo: Arc::new(appointment) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
service_repo: Arc::new(MockServiceRepository::new()) as DynServiceRepository,
availability_repo: Arc::new(MockAvailabilityRepository::new()) as DynAvailabilityRepository,
mailer: Arc::new(mailer) as DynMailer,
whatsapp: Arc::new(whatsapp_mock) as DynWhatsApp,
});
Router::new().route("/webhooks/whatsapp", post(super::whatsapp)).with_state(state)
}
fn message_body(text: &str) -> serde_json::Value {
json!({
"message_id": "3EB0C127D7BA5A4A2E",
"chat_jid": "5541988887777@s.whatsapp.net",
"sender_jid": "5541988887777@s.whatsapp.net",
"sender_phone": "5541988887777",
"text": text,
"timestamp": 1753791234567_i64,
"is_group": false
})
}
fn webhook_post(secret: Option<&str>, body: serde_json::Value) -> Request<Body> {
let mut builder = Request::post("/webhooks/whatsapp");
if let Some(s) = secret {
builder = builder.header("x-webhook-secret", s);
}
builder.json(body)
}
// -- testes -----------------------------------------------------------
/// Secret errado (ou ausente) e 401 sem tocar no banco: o repositorio sem
/// expectativa derruba o teste se qualquer busca acontecer.
#[tokio::test]
async fn the_wrong_secret_is_refused_before_touching_anything() {
let response = app(MockAppointmentRepository::new(), webhook_config())
.oneshot(webhook_post(Some("errado"), message_body("AG-7KM3QF")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let response = app(MockAppointmentRepository::new(), webhook_config())
.oneshot(webhook_post(None, message_body("AG-7KM3QF")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
/// Deploy sem WHATSAPP_WEBHOOK_SECRET nao tem webhook: 503, nunca um 401
/// que sugerisse existir um segredo certo para adivinhar.
#[tokio::test]
async fn an_unconfigured_webhook_is_off() {
let response = app(MockAppointmentRepository::new(), Config::empty())
.oneshot(webhook_post(Some(SECRET), message_body("AG-7KM3QF")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
/// O caminho feliz: a mensagem traz o codigo (no meio de texto editado),
/// o pending confirma e a whatsapp-api recebe 200.
#[tokio::test]
async fn a_message_with_a_code_confirms_the_appointment() {
let mut appointment = MockAppointmentRepository::new();
appointment
.expect_confirm_by_code()
.with(eq("AG-7KM3QF".to_string()))
.times(1)
.return_once(|_| Ok(confirmed_fixture()));
let response = app(appointment, webhook_config())
.oneshot(webhook_post(Some(SECRET), message_body("oi! quero confirmar, codigo AG-7KM3QF, obrigado")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["status"], "confirmed");
}
/// Grupo e conversa sem codigo saem calados: 200 ignored, banco intocado
/// (mock sem expectativa).
#[tokio::test]
async fn groups_and_chatter_are_ignored_in_silence() {
let mut group = message_body("AG-7KM3QF");
group["is_group"] = json!(true);
let response = app(MockAppointmentRepository::new(), webhook_config())
.oneshot(webhook_post(Some(SECRET), group))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response_json(response).await["status"], "ignored");
let response = app(MockAppointmentRepository::new(), webhook_config())
.oneshot(webhook_post(Some(SECRET), message_body("bom dia, voces atendem sabado?")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response_json(response).await["status"], "ignored");
}
/// A reentrega da mesma mensagem (retry da whatsapp-api) responde o mesmo
/// sucesso: o codigo ja confirmado nao e erro.
#[tokio::test]
async fn a_redelivered_code_answers_the_same_success() {
let mut appointment = MockAppointmentRepository::new();
appointment
.expect_confirm_by_code()
.with(eq("AG-7KM3QF".to_string()))
.return_once(|_| Err(DataAccessError::NotFound));
appointment
.expect_get_by_confirmation_code()
.with(eq("AG-7KM3QF".to_string()))
.return_once(|_| Ok(confirmed_fixture()));
let response = app(appointment, webhook_config())
.oneshot(webhook_post(Some(SECRET), message_body("AG-7KM3QF")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response_json(response).await["status"], "confirmed");
}
/// Codigo que nunca existiu (ou de agendamento cancelado): 200 not_found,
/// porque para a whatsapp-api a entrega deu certo.
#[tokio::test]
async fn an_unknown_code_is_not_an_error_for_the_delivery() {
let mut appointment = MockAppointmentRepository::new();
appointment.expect_confirm_by_code().return_once(|_| Err(DataAccessError::NotFound));
appointment.expect_get_by_confirmation_code().return_once(|_| Err(DataAccessError::NotFound));
let response = app(appointment, webhook_config())
.oneshot(webhook_post(Some(SECRET), message_body("AG-ZZZZZZ")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response_json(response).await["status"], "not_found");
}
/// A corrida perdida (pending expirou, outro ficou com o slot) tambem e
/// entrega bem-sucedida: 200 conflict, e a explicacao vai na conversa.
#[tokio::test]
async fn a_lost_race_answers_conflict() {
let mut appointment = MockAppointmentRepository::new();
appointment.expect_confirm_by_code().return_once(|_| Err(DataAccessError::Conflict));
let response = app(appointment, webhook_config())
.oneshot(webhook_post(Some(SECRET), message_body("AG-7KM3QF")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response_json(response).await["status"], "conflict");
}
}
+5
View File
@@ -29,6 +29,10 @@ pub enum AppError {
/// Corpo acima do limite. Separado do BadRequest porque o 413 diz a quem
/// chama exatamente o que fazer: diminuir o arquivo (bug 56).
PayloadTooLarge,
/// Canal opcional que este deploy nao configurou (WhatsApp sem as variaveis
/// WHATSAPP_*). 503 e nao 400: o pedido esta certo, o servidor e que nao
/// oferece o canal.
ChannelUnavailable,
}
impl IntoResponse for AppError {
@@ -56,6 +60,7 @@ impl IntoResponse for AppError {
AppError::InvalidInput => (StatusCode::BAD_REQUEST, "Invalid input"),
AppError::NotFound => (StatusCode::NOT_FOUND, "Not found"),
AppError::PayloadTooLarge => (StatusCode::PAYLOAD_TOO_LARGE, "payload too large"),
AppError::ChannelUnavailable => (StatusCode::SERVICE_UNAVAILABLE, "this channel is not available"),
AppError::BadRequest(txt) => return (StatusCode::BAD_REQUEST, Json(json!({ "error": txt }))).into_response(),
};
(status, Json(json!({ "error": err_msg }))).into_response()
+29
View File
@@ -0,0 +1,29 @@
//! Devolve a grade os pending cujo prazo venceu.
//!
//! A constraint de sobreposicao nao enxerga relogio, entao um pending vencido
//! bloqueia o slot ate alguem mudar o status de verdade: e este worker. Diferente
//! dos lembretes, nao ha claim: o UPDATE condicionado e atomico e idempotente, e
//! duas varreduras concorrentes no maximo dividem as linhas.
use std::{sync::Arc, time::Duration};
use crate::AppState;
/// Um minuto. O menor TTL de confirmacao e 15 min, entao o atraso maximo na
/// devolucao do slot e pequeno perto do proprio prazo.
const SWEEP_INTERVAL: Duration = Duration::from_secs(60);
pub fn spawn(app: Arc<AppState>) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(SWEEP_INTERVAL);
loop {
ticker.tick().await;
match app.appointment_repo.expire_overdue().await {
Ok(0) => {}
Ok(n) => tracing::info!("{n} pending venceram e os horarios voltaram a grade"),
// A proxima varredura tenta de novo; falhar uma nao mata o worker.
Err(err) => tracing::error!("varredura de expiracao falhou: {err}"),
}
}
});
}
+43 -9
View File
@@ -15,6 +15,10 @@ use serde::{Deserialize, Serialize};
use crate::error::AppError;
const PURPOSE: &str = "appointment_access";
/// O token do e-mail "confirme seu horario". Proposito proprio: um link de
/// confirmar vazado nao abre nem cancela o agendamento, e o link de acesso nao
/// confirma nada.
const CONFIRM_PURPOSE: &str = "appointment_confirm";
static KEYS: Lazy<(EncodingKey, DecodingKey)> = Lazy::new(|| {
#[cfg(test)]
@@ -33,27 +37,45 @@ pub struct MagicLinkClaims {
pub exp: u64,
}
/// Vale ate um pouco depois do atendimento: o cliente precisa poder cancelar ate
/// a hora, e nao ha razao para o link sobreviver semanas depois.
pub fn sign(appointment_id: i32, appointment_end: chrono::DateTime<chrono::Utc>) -> Result<String, AppError> {
let exp = (appointment_end + chrono::Duration::days(1)).timestamp().max(0) as u64;
fn sign_with(purpose: &str, appointment_id: i32, expires_at: chrono::DateTime<chrono::Utc>) -> Result<String, AppError> {
let claims = MagicLinkClaims {
purpose: PURPOSE.to_string(),
purpose: purpose.to_string(),
appointment_id,
exp,
exp: expires_at.timestamp().max(0) as u64,
};
encode(&Header::default(), &claims, &KEYS.0).map_err(|_| AppError::TokenCreation)
}
/// Devolve o appointment_id que o token libera, ou erro.
pub fn verify(token: &str) -> Result<i32, AppError> {
fn verify_with(purpose: &str, token: &str) -> Result<i32, AppError> {
let data = decode::<MagicLinkClaims>(token, &KEYS.1, &Validation::default()).map_err(|_| AppError::InvalidToken)?;
if data.claims.purpose != PURPOSE {
if data.claims.purpose != purpose {
return Err(AppError::InvalidToken);
}
Ok(data.claims.appointment_id)
}
/// Vale ate um pouco depois do atendimento: o cliente precisa poder cancelar ate
/// a hora, e nao ha razao para o link sobreviver semanas depois.
pub fn sign(appointment_id: i32, appointment_end: chrono::DateTime<chrono::Utc>) -> Result<String, AppError> {
sign_with(PURPOSE, appointment_id, appointment_end + chrono::Duration::days(1))
}
/// Devolve o appointment_id que o token libera, ou erro.
pub fn verify(token: &str) -> Result<i32, AppError> {
verify_with(PURPOSE, token)
}
/// Vale ate o inicio do horario: confirmar atendimento ja comecado nao faz
/// sentido, e o prazo de verdade (expires_at) quem arbitra e o banco.
pub fn sign_confirmation(appointment_id: i32, appointment_start: chrono::DateTime<chrono::Utc>) -> Result<String, AppError> {
sign_with(CONFIRM_PURPOSE, appointment_id, appointment_start)
}
/// Devolve o appointment_id que o token confirma, ou erro.
pub fn verify_confirmation(token: &str) -> Result<i32, AppError> {
verify_with(CONFIRM_PURPOSE, token)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -110,4 +132,16 @@ mod tests {
let token = sign(1, chrono::Utc::now() - chrono::Duration::days(10)).unwrap();
assert!(verify(&token).is_err());
}
#[test]
fn confirm_and_access_tokens_do_not_cross() {
// Um link de confirmar vazado nao pode abrir nem cancelar o agendamento,
// e o link de acesso nao pode confirmar horario.
let confirm = sign_confirmation(7, future()).unwrap();
assert_eq!(verify_confirmation(&confirm).unwrap(), 7);
assert!(verify(&confirm).is_err());
let access = sign(7, future()).unwrap();
assert!(verify_confirmation(&access).is_err());
}
}
+30
View File
@@ -37,8 +37,10 @@ use crate::{
mod authz;
mod availability;
mod config;
mod confirmation_code;
mod controllers;
mod error;
mod expiration;
mod magic_link;
mod mailer;
mod models;
@@ -48,6 +50,7 @@ mod repositories;
#[cfg(test)]
mod tests;
mod utils;
mod whatsapp;
static KEYS: Lazy<models::professional::Keys> = Lazy::new(|| {
#[cfg(test)]
@@ -67,6 +70,7 @@ pub struct AppState {
service_repo: DynServiceRepository,
availability_repo: DynAvailabilityRepository,
mailer: DynMailer,
whatsapp: whatsapp::DynWhatsApp,
}
#[tokio::main]
@@ -102,6 +106,24 @@ async fn main() {
let service_repo = Arc::new(PgServiceRepository { pool: pool.clone() }) as DynServiceRepository;
let availability_repo = Arc::new(PgAvailabilityRepository { pool: pool.clone() }) as DynAvailabilityRepository;
let mailer = Arc::new(SmtpMailer::new(&config.smtp_url, config.mail_from.clone()).expect("invalid SMTP_URL")) as DynMailer;
// As quatro WHATSAPP_* andam juntas: canal configurado pela metade e
// surpresa em producao (pending que ninguem consegue confirmar, ou webhook
// mudo). Melhor nao subir.
let wa_set = [
config.whatsapp_api_url.is_some(),
config.whatsapp_api_key.is_some(),
config.whatsapp_number.is_some(),
config.whatsapp_webhook_secret.is_some(),
];
if wa_set.iter().any(|s| *s) && !wa_set.iter().all(|s| *s) {
panic!("configure as quatro variaveis WHATSAPP_* (API_URL, API_KEY, NUMBER, WEBHOOK_SECRET) juntas, ou nenhuma");
}
let whatsapp = match (&config.whatsapp_api_url, &config.whatsapp_api_key) {
(Some(url), Some(key)) => Arc::new(whatsapp::HttpWhatsApp::new(url, key.clone())) as whatsapp::DynWhatsApp,
_ => Arc::new(whatsapp::Disabled) as whatsapp::DynWhatsApp,
};
let shared_state = Arc::new(AppState {
config: config.clone(),
company_repo,
@@ -111,9 +133,11 @@ async fn main() {
service_repo,
availability_repo,
mailer,
whatsapp,
});
reminders::spawn(shared_state.clone());
expiration::spawn(shared_state.clone());
// Em axum, .layer so envolve rota ja adicionada: uma camada CORS aplicada
// ao Router::new() vazio nao fazia nada. A que vale e a permissive() abaixo
@@ -129,6 +153,11 @@ async fn main() {
.nest_service("/avatars", ServeDir::new(&config.upload_dir)),
)
.nest("/api/v1/public", public_routes())
// O callback da whatsapp-api: publico de proposito (quem chama e um
// servico, sem JWT), barrado pelo X-Webhook-Secret dentro do handler.
// Fora do governor: a origem e um servico conhecido, e um 429 num retry
// so atrasaria confirmacao de horario.
.route("/api/v1/webhooks/whatsapp", post(controllers::webhook::whatsapp))
.route("/metrics", get(get_metrics))
.layer(CorsLayer::permissive())
.layer(
@@ -206,6 +235,7 @@ pub fn public_routes() -> Router<Arc<AppState>> {
.route("/{slug}/appointment", post(controllers::public::create_appointment))
.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))
.layer(GovernorLayer::new(governor))
}
+21 -1
View File
@@ -20,6 +20,14 @@ pub struct Appointment {
/// Marcado pelo worker ANTES de enviar, no mesmo UPDATE que seleciona.
#[serde(skip_serializing)]
pub reminder_sent_at: Option<chrono::DateTime<chrono::Utc>>,
/// So agendamento publico tem: quem apresenta o codigo confirma o horario,
/// entao ele nao sai em resposta nenhuma.
#[serde(skip_serializing)]
pub confirmation_code: Option<String>,
/// Prazo do pending. Vencido, o worker de expiracao devolve o slot a grade;
/// o codigo segue confirmavel enquanto ninguem tomar o horario.
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// Os estados que o CHECK `appointments_status_check` aceita. O banco continua
@@ -28,30 +36,40 @@ pub struct Appointment {
/// literais dos handlers). Mesmo padrao do `authz::Role`: string nao decide.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppointmentStatus {
/// Agendamento publico aguardando o cliente provar o canal (codigo pelo
/// WhatsApp ou clique no e-mail). Trava o slot como confirmed.
Pending,
Confirmed,
CancelledByClient,
CancelledByCompany,
Completed,
NoShow,
/// Pending cujo prazo venceu: o slot voltou a grade. Ainda vira confirmed
/// se o codigo chegar e ninguem tiver tomado o horario.
Expired,
}
impl AppointmentStatus {
pub const ALL: [AppointmentStatus; 5] = [
pub const ALL: [AppointmentStatus; 7] = [
AppointmentStatus::Pending,
AppointmentStatus::Confirmed,
AppointmentStatus::CancelledByClient,
AppointmentStatus::CancelledByCompany,
AppointmentStatus::Completed,
AppointmentStatus::NoShow,
AppointmentStatus::Expired,
];
/// Entrada do usuario: desconhecido e recusado, nunca adivinhado.
pub fn try_parse(raw: &str) -> Option<Self> {
match raw {
"pending" => Some(AppointmentStatus::Pending),
"confirmed" => Some(AppointmentStatus::Confirmed),
"cancelled_by_client" => Some(AppointmentStatus::CancelledByClient),
"cancelled_by_company" => Some(AppointmentStatus::CancelledByCompany),
"completed" => Some(AppointmentStatus::Completed),
"no_show" => Some(AppointmentStatus::NoShow),
"expired" => Some(AppointmentStatus::Expired),
_ => None,
}
}
@@ -59,11 +77,13 @@ impl AppointmentStatus {
/// A grafia que a coluna guarda e o CHECK conhece.
pub fn as_str(self) -> &'static str {
match self {
AppointmentStatus::Pending => "pending",
AppointmentStatus::Confirmed => "confirmed",
AppointmentStatus::CancelledByClient => "cancelled_by_client",
AppointmentStatus::CancelledByCompany => "cancelled_by_company",
AppointmentStatus::Completed => "completed",
AppointmentStatus::NoShow => "no_show",
AppointmentStatus::Expired => "expired",
}
}
}
+75
View File
@@ -52,6 +52,47 @@ pub fn confirmation(
})
}
/// O horario ainda e pending: este e-mail pede o clique que confirma. O link e
/// a prova de posse do canal, entao ele so confirma (purpose proprio), nao abre
/// nem cancela o agendamento.
pub fn confirm_request(
company: &Company,
client: &Client,
service: &Service,
professional: &Professional,
appointment: &Appointment,
confirm_link: &str,
) -> Option<Email> {
let to = client.email.clone().filter(|e| !e.is_empty())?;
Some(Email {
to,
subject: format!("Confirme seu horario em {}", company.name),
body: format!(
"Ola, {}!\n\nFalta um passo: confirme para garantir o seu horario.\n\nServico: {} ({})\nProfissional: {}\nQuando: {}\n\nConfirme aqui:\n{}\n\nSe voce nao pediu este agendamento, ignore este e-mail.\n",
client.name,
service.name,
money(service.price_cents),
professional.name,
local(appointment.start, &company.timezone),
confirm_link,
),
})
}
/// A resposta no WhatsApp para quem mandou o codigo: fecha o ciclo (a pessoa
/// enviou e recebeu o "deu certo" no mesmo lugar) e carrega o magic link, que
/// no canal WhatsApp substitui o e-mail como portador do acesso.
pub fn whatsapp_confirmed(company: &Company, service: Option<&Service>, appointment: &Appointment, link: &str) -> String {
let service_line = service.map(|s| format!("Servico: {}\n", s.name)).unwrap_or_default();
format!(
"Confirmado! Seu horario em {}:\n\n{}Quando: {}\n\nPara ver ou cancelar:\n{}",
company.name,
service_line,
local(appointment.start, &company.timezone),
link,
)
}
/// O horario mudou: o cliente recebe a hora nova e um link NOVO. A validade do
/// link congela na assinatura (fim + 1 dia), entao remarcar para depois vencia
/// o link que o cliente tinha (bug #42). Encaixe remarcado avisa sem servico.
@@ -196,6 +237,8 @@ mod tests {
status: "confirmed".to_string(),
service_id: Some(1),
reminder_sent_at: None,
confirmation_code: None,
expires_at: None,
}
}
@@ -263,6 +306,38 @@ mod tests {
assert!(email.body.contains("Novo horario"));
}
#[test]
fn the_confirm_request_asks_for_the_click_and_says_nothing_is_guaranteed_yet() {
let email = confirm_request(
&company(),
&client(Some("j@e.com")),
&service(),
&professional("p@e.com"),
&appointment(),
"https://link/confirmar/abc",
)
.unwrap();
assert!(email.subject.contains("Confirme"), "assunto: {}", email.subject);
assert!(email.body.contains("https://link/confirmar/abc"));
assert!(email.body.contains("02/09/2030 as 11:00"), "corpo: {}", email.body);
// Quem nao pediu o agendamento precisa saber que pode ignorar.
assert!(email.body.contains("ignore"), "corpo: {}", email.body);
assert!(confirm_request(&company(), &client(None), &service(), &professional("p@e.com"), &appointment(), "x").is_none());
}
#[test]
fn the_whatsapp_reply_confirms_in_local_time_and_carries_the_link() {
let text = whatsapp_confirmed(&company(), Some(&service()), &appointment(), "https://link/abc");
assert!(text.contains("Confirmado"), "{text}");
assert!(text.contains("Corte"));
assert!(text.contains("02/09/2030 as 11:00"), "{text}");
assert!(text.contains("https://link/abc"));
// Encaixe sem servico confirma sem a linha de servico.
let text = whatsapp_confirmed(&company(), None, &appointment(), "x");
assert!(!text.contains("Servico"), "{text}");
}
#[test]
fn the_reminder_invites_cancelling_to_free_the_slot() {
let email = reminder(&company(), &client(Some("j@e.com")), &service(), &appointment(), "https://link").unwrap();
+4
View File
@@ -132,6 +132,8 @@ mod tests {
status: "confirmed".to_string(),
service_id,
reminder_sent_at: Some(chrono::Utc::now()),
confirmation_code: None,
expires_at: None,
}
}
@@ -144,6 +146,7 @@ mod tests {
appointment_repo: Arc::new(appointment_mock) as DynAppointmentRepository,
client_repo: Arc::new(client_mock) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
})
}
@@ -228,6 +231,7 @@ mod tests {
appointment_repo: Arc::new(appointment_mock) as DynAppointmentRepository,
client_repo: Arc::new(client_mock) as DynClientRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
whatsapp: Arc::new(crate::whatsapp::MockWhatsApp::new()) as crate::whatsapp::DynWhatsApp,
config: Config::empty(),
});
+87 -1
View File
@@ -23,6 +23,22 @@ pub type DynAppointmentRepository = Arc<dyn AppointmentRepository + Send + Sync>
#[async_trait]
pub trait AppointmentRepository {
async fn create_appointment(&self, appointment: NewAppointment) -> Result<i32, DataAccessError>;
/// Agendamento publico: nasce 'pending' com o codigo que confirma e o prazo.
/// Trava o slot desde ja (a constraint cobre pending), mas so vira compromisso
/// quando o cliente prova o canal.
async fn create_pending(&self, appointment: NewAppointment, code: String, expires_at: chrono::DateTime<chrono::Utc>) -> Result<i32, DataAccessError>;
/// Confirma pelo codigo (caminho do WhatsApp). pending E expired confirmam:
/// a expiracao so devolve o slot a grade, e se ninguem o tomou nao ha razao
/// para recusar. Quem arbitra a corrida e a constraint: Conflict = alguem
/// ficou com o horario. NotFound = codigo inexistente ou ja fora desses dois
/// estados (distinga com get_by_confirmation_code).
async fn confirm_by_code(&self, code: String) -> Result<Appointment, DataAccessError>;
/// Confirma pelo id (caminho do e-mail; o id vem do token assinado).
async fn confirm_by_id(&self, id: i32) -> Result<Appointment, DataAccessError>;
async fn get_by_confirmation_code(&self, code: String) -> Result<Appointment, DataAccessError>;
/// Devolve a grade os pending cujo prazo venceu. Idempotente: e um UPDATE
/// 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>;
/// So o que ocupa a agenda, e so na janela pedida. Espelha o WHERE da
/// constraint appointments_no_overlap.
@@ -80,6 +96,76 @@ impl AppointmentRepository for PgAppointmentRepository {
Ok(result.id)
}
async fn create_pending(&self, appointment: NewAppointment, code: String, expires_at: chrono::DateTime<chrono::Utc>) -> Result<i32, DataAccessError> {
let result = sqlx::query_as!(
Returning,
r#"
INSERT INTO appointments
("start", "end", "professional_id", "client_id", "company_id", "service_id", "description", "status", "confirmation_code", "expires_at")
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8, $9)
returning id
"#,
appointment.start,
appointment.end,
appointment.professional_id,
appointment.client_id,
appointment.company_id,
appointment.service_id,
appointment.description,
code,
expires_at
)
.fetch_one(&self.pool)
.await
.map_err(|e| DataAccessError::from_sqlx("appointment::create_pending", e))?;
Ok(result.id)
}
async fn confirm_by_code(&self, code: String) -> Result<Appointment, DataAccessError> {
sqlx::query_as!(
Appointment,
r#"
UPDATE appointments SET status = 'confirmed'
WHERE confirmation_code = $1 AND status IN ('pending', 'expired')
RETURNING *
"#,
code
)
.fetch_one(&self.pool)
.await
.map_err(|e| DataAccessError::from_sqlx("appointment::confirm_by_code", e))
}
async fn confirm_by_id(&self, id: i32) -> Result<Appointment, DataAccessError> {
sqlx::query_as!(
Appointment,
r#"
UPDATE appointments SET status = 'confirmed'
WHERE id = $1 AND status IN ('pending', 'expired')
RETURNING *
"#,
id
)
.fetch_one(&self.pool)
.await
.map_err(|e| DataAccessError::from_sqlx("appointment::confirm_by_id", e))
}
async fn get_by_confirmation_code(&self, code: String) -> Result<Appointment, DataAccessError> {
sqlx::query_as!(Appointment, "SELECT * FROM appointments WHERE confirmation_code = $1", code)
.fetch_one(&self.pool)
.await
.map_err(|e| DataAccessError::from_sqlx("appointment::get_by_confirmation_code", e))
}
async fn expire_overdue(&self) -> Result<u64, DataAccessError> {
let result = sqlx::query!("UPDATE appointments SET status = 'expired' WHERE status = 'pending' AND expires_at < NOW()")
.execute(&self.pool)
.await
.map_err(|e| DataAccessError::from_sqlx("appointment::expire_overdue", e))?;
Ok(result.rows_affected())
}
async fn get_appointments_by_professional_id(&self, professional_id: i32) -> Result<Vec<Appointment>, DataAccessError> {
sqlx::query_as!(Appointment, "SELECT * FROM appointments WHERE professional_id = $1", professional_id)
.fetch_all(&self.pool)
@@ -134,7 +220,7 @@ impl AppointmentRepository for PgAppointmentRepository {
Appointment,
r#"
SELECT * FROM appointments
WHERE professional_id = $1 AND status = 'confirmed' AND "start" < $3 AND "end" > $2
WHERE professional_id = $1 AND status IN ('pending', 'confirmed') AND "start" < $3 AND "end" > $2
ORDER BY "start"
"#,
professional_id,
+89
View File
@@ -0,0 +1,89 @@
//! Envio de mensagens WhatsApp pela whatsapp-api (servico separado, com sessao
//! propria pareada por QR).
//!
//! Mesmo desenho do Mailer: trait com implementacao trocavel, e nenhuma falha
//! daqui desfaz nada. Quando a resposta sai, o horario ja esta confirmado no
//! banco; se o envio falhar, o cliente ainda tem a pagina (polling do status) e
//! o erro vai para o log.
//!
//! Quem inicia a conversa e SEMPRE o cliente (link wa.me): daqui so saem
//! respostas dentro de uma conversa existente. E isso que mantem o numero longe
//! do bloqueio por spam, e nao e detalhe de implementacao: nao adicione envio
//! proativo aqui sem repensar o canal.
use std::sync::Arc;
use async_trait::async_trait;
#[cfg(test)]
use mockall::automock;
pub type DynWhatsApp = Arc<dyn WhatsApp + Send + Sync>;
#[cfg_attr(test, automock)]
#[async_trait]
pub trait WhatsApp {
/// `to` e numero com DDI e so digitos (5541999998888), como a whatsapp-api
/// espera no campo `to`.
async fn send_text(&self, to: String, message: String) -> anyhow::Result<()>;
}
/// Producao: POST {base}/messages/text, autenticado por X-API-Key.
pub struct HttpWhatsApp {
base_url: String,
api_key: String,
client: reqwest::Client,
}
impl HttpWhatsApp {
pub fn new(base_url: &str, api_key: String) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
api_key,
client: reqwest::Client::new(),
}
}
}
#[async_trait]
impl WhatsApp for HttpWhatsApp {
async fn send_text(&self, to: String, message: String) -> anyhow::Result<()> {
let response = self
.client
.post(format!("{}/messages/text", self.base_url))
.header("X-API-Key", &self.api_key)
.json(&serde_json::json!({ "to": to, "message": message }))
.timeout(std::time::Duration::from_secs(10))
.send()
.await?;
if !response.status().is_success() {
// O corpo de erro da whatsapp-api e um JSON {status, error} curto:
// sem ele o log diria so "500", longe da causa (sessao caida, 422...).
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("whatsapp-api respondeu {status}: {body}");
}
Ok(())
}
}
/// Sem WHATSAPP_API_URL configurada. Os handlers barram o canal antes (503 na
/// criacao); se algum caminho ainda enviar, falha aqui e o erro vai para o log
/// como qualquer falha de envio.
pub struct Disabled;
#[async_trait]
impl WhatsApp for Disabled {
async fn send_text(&self, _to: String, _message: String) -> anyhow::Result<()> {
anyhow::bail!("canal WhatsApp desligado: WHATSAPP_API_URL nao configurada")
}
}
/// Dispara o envio e devolve na hora, igual ao notify::send_in_background: o
/// request nao espera a whatsapp-api, e a falha vira log dentro da task.
pub fn send_in_background(whatsapp: DynWhatsApp, to: String, message: String) {
tokio::spawn(async move {
if let Err(err) = whatsapp.send_text(to.clone(), message).await {
tracing::error!("falha ao enviar WhatsApp para {to}: {err}");
}
});
}
+85
View File
@@ -100,6 +100,91 @@ async fn reactivating_a_cancelled_overlap_is_rejected(pool: PgPool) {
assert_eq!(sqlstate(&err), EXCLUSION_VIOLATION);
}
#[sqlx::test]
async fn a_pending_appointment_holds_the_slot_like_a_confirmed_one(pool: PgPool) {
// A corrida pelo horario deve acontecer na escolha, nao depois de o cliente
// ja ter mandado o WhatsApp: pending trava o slot contra pending E confirmed.
insert_appointment(&pool, "2030-09-01 10:00+00", "2030-09-01 11:00+00", "pending")
.await
.expect("o pending deve entrar");
let err = insert_appointment(&pool, "2030-09-01 10:30+00", "2030-09-01 11:30+00", "pending")
.await
.expect_err("outro pending sobreposto deve ser recusado");
assert_eq!(sqlstate(&err), EXCLUSION_VIOLATION);
let err = insert_appointment(&pool, "2030-09-01 10:30+00", "2030-09-01 11:30+00", "confirmed")
.await
.expect_err("confirmado sobreposto ao pending deve ser recusado");
assert_eq!(sqlstate(&err), EXCLUSION_VIOLATION);
}
#[sqlx::test]
async fn an_expired_pending_frees_the_slot(pool: PgPool) {
let id = insert_appointment(&pool, "2030-09-01 10:00+00", "2030-09-01 11:00+00", "pending")
.await
.expect("o pending deve entrar");
sqlx::query("UPDATE appointments SET status = 'expired' WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.expect("expirar deve funcionar");
insert_appointment(&pool, "2030-09-01 10:00+00", "2030-09-01 11:00+00", "pending")
.await
.expect("o slot do pending expirado deve estar livre");
}
#[sqlx::test]
async fn confirming_an_expired_code_loses_to_whoever_took_the_slot(pool: PgPool) {
// O refinamento do fluxo: codigo expirado ainda confirma ENQUANTO o slot
// estiver livre; quem arbitra e a constraint, tambem no UPDATE.
let expired = insert_appointment(&pool, "2030-09-01 10:00+00", "2030-09-01 11:00+00", "expired")
.await
.expect("o expirado deve entrar");
// Sem concorrente, a confirmacao tardia funciona.
sqlx::query("UPDATE appointments SET status = 'confirmed' WHERE id = $1")
.bind(expired)
.execute(&pool)
.await
.expect("confirmar expirado com o slot livre deve funcionar");
// Agora o cenario com concorrente: outro expirado no mesmo horario nao
// consegue mais confirmar.
let loser = insert_appointment(&pool, "2030-09-01 10:15+00", "2030-09-01 10:45+00", "expired")
.await
.expect("expirado sobreposto pode existir");
let err = sqlx::query("UPDATE appointments SET status = 'confirmed' WHERE id = $1")
.bind(loser)
.execute(&pool)
.await
.expect_err("confirmar expirado com o slot tomado deve ser recusado");
assert_eq!(sqlstate(&err), EXCLUSION_VIOLATION);
}
#[sqlx::test]
async fn confirmation_codes_are_unique(pool: PgPool) {
sqlx::query(
r#"INSERT INTO appointments ("start", "end", professional_id, client_id, company_id, status, confirmation_code)
VALUES ('2030-09-01 10:00+00', '2030-09-01 11:00+00', 1, 1, 1, 'pending', 'AG-AAAAAA')"#,
)
.execute(&pool)
.await
.expect("o primeiro codigo deve entrar");
let err = sqlx::query(
r#"INSERT INTO appointments ("start", "end", professional_id, client_id, company_id, status, confirmation_code)
VALUES ('2030-09-01 12:00+00', '2030-09-01 13:00+00', 1, 1, 1, 'pending', 'AG-AAAAAA')"#,
)
.execute(&pool)
.await
.expect_err("codigo repetido deve ser recusado");
assert_eq!(sqlstate(&err), "23505");
}
#[sqlx::test]
async fn appointments_reject_unknown_status(pool: PgPool) {
let err = insert_appointment(&pool, "2030-09-01 10:00+00", "2030-09-01 11:00+00", "inventado")