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
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:
@@ -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=
|
||||
|
||||
+24
@@ -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
|
||||
]
|
||||
},
|
||||
|
||||
+184
@@ -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"
|
||||
}
|
||||
+186
@@ -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"
|
||||
}
|
||||
+184
@@ -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"
|
||||
}
|
||||
+27
-5
@@ -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"
|
||||
}
|
||||
+24
@@ -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
|
||||
]
|
||||
},
|
||||
|
||||
+24
@@ -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
|
||||
]
|
||||
},
|
||||
|
||||
+12
@@ -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"
|
||||
}
|
||||
+36
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,4 @@ pub mod professional;
|
||||
pub mod public;
|
||||
pub mod service;
|
||||
pub mod upload;
|
||||
pub mod webhook;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user