Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bee7a7940 | ||
|
|
1bd3e17672 | ||
|
|
91ca486e64 | ||
|
|
2389bca42b | ||
|
|
520960d009 | ||
|
|
0cda791d44 | ||
|
|
a65488c304 | ||
|
|
4d9d0a894c | ||
|
|
fd1e78ba8c | ||
|
|
99e2cbc1f0 | ||
|
|
71f444b9aa |
@@ -0,0 +1,60 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Pet sitting web service for managing clients and bookings. The owner uses the site to:
|
||||
- Receive and manage client requests (leads) from the website
|
||||
- Schedule calls and visits with clients
|
||||
- Upload photos/videos of pets for remote viewing by clients (public media page via unique token)
|
||||
- Get Telegram notifications about new requests
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Language:** Rust (edition 2024)
|
||||
- **Web framework:** [Cot](https://github.com/cot-rs/cot) - Rust web framework (Django-like), local path `../cot/cot`
|
||||
- **Database:** PostgreSQL (via Cot ORM)
|
||||
- **Notifications:** Telegram Bot API
|
||||
|
||||
## Build & Run
|
||||
|
||||
```sh
|
||||
cargo build # build
|
||||
cargo run # run dev server at http://127.0.0.1:8000
|
||||
cargo test # run all tests
|
||||
cargo test <name> # run a single test by name
|
||||
cargo clippy # lint
|
||||
cargo fmt --check # check formatting
|
||||
cot migration make # generate migrations from model changes (requires cot-cli)
|
||||
```
|
||||
|
||||
Set `WEB_PETTING_DATABASE_URL` (or `DATABASE_URL`) before running the app or migrations. Example:
|
||||
|
||||
```sh
|
||||
WEB_PETTING_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/web_petting cargo run
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Monolithic Cot web app with a single PostgreSQL database.
|
||||
|
||||
- `src/main.rs` - project/app setup, router, config
|
||||
- `src/models.rs` - all database models (Lead, Client, Visit, Media, User, Setting)
|
||||
- `src/migrations.rs` - migration registry
|
||||
- `src/migrations/` - migration files
|
||||
|
||||
## Database Design Principles
|
||||
|
||||
- **Soft-delete everywhere:** records are never physically deleted, only status changes (e.g. `active` -> `archived`, `new` -> `rejected`). This ensures data can always be recovered.
|
||||
- **Status fields** are stored as `String` with enum-like values defined in `models.rs`.
|
||||
- **Foreign keys** use `cot::db::ForeignKey<T>` with `Restrict` on delete/update.
|
||||
|
||||
## Data Model
|
||||
|
||||
- **Lead** (`new`/`in_progress`/`converted`/`rejected`) - public form submission; links to Client when converted
|
||||
- **Client** (`active`/`archived`) - confirmed client with `media_token` for public media page
|
||||
- **Visit** (`scheduled`/`completed`/`cancelled`/`deleted`) - pet sitting session, belongs to Client and User
|
||||
- **Media** (`active`/`archived`) - photo/video, belongs to Client, optionally to Visit
|
||||
- **User** (`active`/`archived`) - admin accounts (supports multiple admins)
|
||||
- **Setting** - global key-value config (telegram_bot_token, telegram_chat_id, etc.)
|
||||
@@ -13,30 +13,36 @@ Pet sitting web service for managing clients and bookings. The owner uses the si
|
||||
## Tech Stack
|
||||
|
||||
- **Language:** Rust (edition 2024)
|
||||
- **Web framework:** [Cot](https://github.com/cot-rs/cot) — Rust web framework (Django-like), local path `../cot/cot`
|
||||
- **Database:** SQLite (via Cot ORM), file `db.sqlite3`
|
||||
- **Web framework:** [Cot](https://github.com/cot-rs/cot) - Rust web framework (Django-like), local path `../cot/cot`
|
||||
- **Database:** PostgreSQL (via Cot ORM)
|
||||
- **Notifications:** Telegram Bot API
|
||||
|
||||
## Build & Run
|
||||
|
||||
```sh
|
||||
cargo build # build
|
||||
cargo run # run dev server at http://127.0.0.1:8000
|
||||
cargo test # run all tests
|
||||
cargo test <name> # run a single test by name
|
||||
cargo clippy # lint
|
||||
cargo fmt --check # check formatting
|
||||
cot migration make # generate migrations from model changes (requires cot-cli)
|
||||
cargo build # build
|
||||
cargo run # run dev server at http://127.0.0.1:8000
|
||||
cargo test # run all tests
|
||||
cargo test <name> # run a single test by name
|
||||
cargo clippy # lint
|
||||
cargo fmt --check # check formatting
|
||||
cot migration make # generate migrations from model changes (requires cot-cli)
|
||||
```
|
||||
|
||||
Set `WEB_PETTING_DATABASE_URL` (or `DATABASE_URL`) before running the app or migrations. Example:
|
||||
|
||||
```sh
|
||||
WEB_PETTING_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/web_petting cargo run
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Monolithic Cot web app with a single SQLite database.
|
||||
Monolithic Cot web app with a single PostgreSQL database.
|
||||
|
||||
- `src/main.rs` — project/app setup, router, config
|
||||
- `src/models.rs` — all database models (Lead, Client, Visit, Media, User, Setting)
|
||||
- `src/migrations.rs` — migration registry (auto-generated by `cot migration make`)
|
||||
- `src/migrations/` — migration files (auto-generated)
|
||||
- `src/main.rs` - project/app setup, router, config
|
||||
- `src/models.rs` - all database models (Lead, Client, Visit, Media, User, Setting)
|
||||
- `src/migrations.rs` - migration registry
|
||||
- `src/migrations/` - migration files
|
||||
|
||||
## Database Design Principles
|
||||
|
||||
@@ -46,9 +52,9 @@ Monolithic Cot web app with a single SQLite database.
|
||||
|
||||
## Data Model
|
||||
|
||||
- **Lead** (`new`/`in_progress`/`converted`/`rejected`) — public form submission; links to Client when converted
|
||||
- **Client** (`active`/`archived`) — confirmed client with `media_token` for public media page
|
||||
- **Visit** (`scheduled`/`completed`/`cancelled`) — pet sitting session, belongs to Client
|
||||
- **Media** (`active`/`archived`) — photo/video, belongs to Client, optionally to Visit
|
||||
- **User** (`active`/`archived`) — admin accounts (supports multiple admins)
|
||||
- **Setting** — global key-value config (telegram_bot_token, telegram_chat_id, etc.)
|
||||
- **Lead** (`new`/`in_progress`/`converted`/`rejected`) - public form submission; links to Client when converted
|
||||
- **Client** (`active`/`archived`) - confirmed client with `media_token` for public media page
|
||||
- **Visit** (`scheduled`/`completed`/`cancelled`/`deleted`) - pet sitting session, belongs to Client and User
|
||||
- **Media** (`active`/`archived`) - photo/video, belongs to Client, optionally to Visit
|
||||
- **User** (`active`/`archived`) - admin accounts (supports multiple admins)
|
||||
- **Setting** - global key-value config (telegram_bot_token, telegram_chat_id, etc.)
|
||||
|
||||
Generated
+133
-2
@@ -332,12 +332,24 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -630,6 +642,15 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
@@ -893,12 +914,31 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -1434,6 +1474,32 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -1551,7 +1617,6 @@ version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
@@ -1637,6 +1702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1650,6 +1716,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
@@ -1915,6 +1991,19 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -1967,6 +2056,18 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -2455,6 +2556,12 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
@@ -3182,6 +3289,12 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -3353,12 +3466,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-petting"
|
||||
version = "0.1.11"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"cot",
|
||||
"futures",
|
||||
"image",
|
||||
"multer",
|
||||
"password-auth",
|
||||
"reqwest",
|
||||
@@ -3368,6 +3483,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -3908,3 +4024,18 @@ name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
+5
-2
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "web-petting"
|
||||
version = "0.1.11"
|
||||
version = "1.0.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
cot = { version = "0.6.0", features = ["sqlite"] }
|
||||
cot = { version = "0.6.0", default-features = false, features = ["postgres", "json"] }
|
||||
chrono = "0.4"
|
||||
chrono-tz = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -14,7 +14,10 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
|
||||
serde_json = "1"
|
||||
multer = "3"
|
||||
futures = "0.3"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
tokio = { version = "1", features = ["fs"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
base64 = "0.22"
|
||||
urlencoding = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
@@ -9,6 +9,7 @@ RUN cargo build --release
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /data
|
||||
ENV WEB_PETTING_UPLOAD_DIR=/data/uploads
|
||||
COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting
|
||||
COPY static /app/static
|
||||
EXPOSE 3000
|
||||
|
||||
+649
-40
@@ -6,7 +6,13 @@ use cot::request::extractors::Path;
|
||||
use cot::response::{IntoResponse, Redirect, Response};
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use image::ImageDecoder;
|
||||
use image::ImageFormat;
|
||||
use image::ImageReader;
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::imageops::FilterType;
|
||||
use serde::Deserialize;
|
||||
use std::io::Cursor;
|
||||
|
||||
use crate::i18n::{Lang, Translations};
|
||||
use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit};
|
||||
@@ -14,6 +20,9 @@ use crate::telegram;
|
||||
|
||||
const SESSION_USER_ID: &str = "user_id";
|
||||
const SESSION_USER_NAME: &str = "user_name";
|
||||
const SESSION_OIDC_STATE: &str = "oidc_state";
|
||||
const MAX_UPLOADED_IMAGE_DIMENSION: u32 = 1920;
|
||||
const UPLOADED_IMAGE_JPEG_QUALITY: u8 = 82;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -88,6 +97,74 @@ fn has_query_flag(request: &Request, flag: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn get_query_param(request: &Request, key: &str) -> Option<String> {
|
||||
let prefix = format!("{}=", key);
|
||||
request.uri().query().and_then(|q| {
|
||||
q.split('&')
|
||||
.find_map(|p| p.strip_prefix(&prefix).map(|v| v.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
fn image_format_from_ext(ext: &str) -> Option<ImageFormat> {
|
||||
match ext {
|
||||
"jpg" | "jpeg" => Some(ImageFormat::Jpeg),
|
||||
"png" => Some(ImageFormat::Png),
|
||||
"webp" => Some(ImageFormat::WebP),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn transcode_uploaded_image(data: &[u8], ext: &str) -> cot::Result<Option<Vec<u8>>> {
|
||||
let Some(format) = image_format_from_ext(ext) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut decoder = ImageReader::with_format(Cursor::new(data), format)
|
||||
.into_decoder()
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let orientation = decoder
|
||||
.orientation()
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let mut image = image::DynamicImage::from_decoder(decoder)
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
image.apply_orientation(orientation);
|
||||
|
||||
let resized = image.resize(
|
||||
MAX_UPLOADED_IMAGE_DIMENSION,
|
||||
MAX_UPLOADED_IMAGE_DIMENSION,
|
||||
FilterType::Lanczos3,
|
||||
);
|
||||
let rgb = resized.to_rgb8();
|
||||
let mut encoded = Vec::new();
|
||||
let mut encoder = JpegEncoder::new_with_quality(&mut encoded, UPLOADED_IMAGE_JPEG_QUALITY);
|
||||
encoder
|
||||
.encode_image(&rgb)
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
Ok(Some(encoded))
|
||||
}
|
||||
|
||||
async fn save_uploaded_image(
|
||||
upload_dir: &str,
|
||||
file_id: uuid::Uuid,
|
||||
ext: &str,
|
||||
data: &[u8],
|
||||
) -> cot::Result<String> {
|
||||
if let Some(encoded) = transcode_uploaded_image(data, ext)? {
|
||||
let path = crate::uploads::join_db_path(upload_dir, &format!("{file_id}.jpg"));
|
||||
crate::uploads::write_db_file(&path, &encoded)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Ok(path)
|
||||
} else {
|
||||
let path = crate::uploads::join_db_path(upload_dir, &format!("{file_id}.{ext}"));
|
||||
crate::uploads::write_db_file(&path, data)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft pastel palette for client calendar colors.
|
||||
const CLIENT_COLORS: &[&str] = &[
|
||||
"#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", "#8e6bbf", "#5cb8a5", "#c77c4f",
|
||||
@@ -194,6 +271,8 @@ struct LoginTemplate<'a> {
|
||||
lang: Lang,
|
||||
error: Option<String>,
|
||||
turnstile_site_key: String,
|
||||
auth_password_enabled: bool,
|
||||
auth_sso_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
@@ -255,6 +334,8 @@ struct SettingsTemplate<'a> {
|
||||
admin_name: &'a str,
|
||||
settings: Vec<Setting>,
|
||||
saved: bool,
|
||||
auth_password_checked: bool,
|
||||
auth_sso_checked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
@@ -285,7 +366,7 @@ struct ScheduleEditTemplate<'a> {
|
||||
lang: Lang,
|
||||
admin_name: &'a str,
|
||||
visit: Visit,
|
||||
clients: Vec<Client>,
|
||||
client: Client,
|
||||
users: Vec<User>,
|
||||
media: Vec<Media>,
|
||||
}
|
||||
@@ -348,11 +429,54 @@ async fn login_page(request: Request, session: Session, db: Database) -> cot::Re
|
||||
}
|
||||
|
||||
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
||||
|
||||
let settings = Setting::objects().all(&db).await?;
|
||||
let get_val = |key: &str| -> String {
|
||||
settings
|
||||
.iter()
|
||||
.find(|s| s.key == key)
|
||||
.map(|s| s.value.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let password_setting = get_val("auth_password_enabled");
|
||||
let sso_setting = get_val("auth_sso_enabled");
|
||||
let oidc_configured = !get_val("oidc_issuer_url").trim().is_empty();
|
||||
|
||||
// Default: password enabled if setting was never saved
|
||||
let auth_password_enabled = if password_setting.is_empty() {
|
||||
true
|
||||
} else {
|
||||
password_setting == "true"
|
||||
};
|
||||
let auth_sso_enabled = sso_setting == "true" && oidc_configured;
|
||||
|
||||
// Fallback: if neither is enabled, show password form
|
||||
let (auth_password_enabled, auth_sso_enabled) = if !auth_password_enabled && !auth_sso_enabled {
|
||||
(true, false)
|
||||
} else {
|
||||
(auth_password_enabled, auth_sso_enabled)
|
||||
};
|
||||
|
||||
let error = get_query_param(&request, "error").map(|code| {
|
||||
let t = lang.t();
|
||||
match code.as_str() {
|
||||
"sso_group" => t.login_sso_error_group,
|
||||
"sso_provider" => t.login_sso_error_provider,
|
||||
"sso_disabled" => t.login_sso_error_user_disabled,
|
||||
"sso" => t.login_sso_error,
|
||||
_ => t.login_sso_error,
|
||||
}
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let body = LoginTemplate {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
error: None,
|
||||
error,
|
||||
turnstile_site_key,
|
||||
auth_password_enabled,
|
||||
auth_sso_enabled,
|
||||
}
|
||||
.render()?;
|
||||
html_response(body, lang)
|
||||
@@ -442,6 +566,8 @@ async fn login_submit(request: Request, session: Session, db: Database) -> cot::
|
||||
lang,
|
||||
error: Some(lang.t().login_error.to_string()),
|
||||
turnstile_site_key,
|
||||
auth_password_enabled: true,
|
||||
auth_sso_enabled: false,
|
||||
}
|
||||
.render()?;
|
||||
return html_response(body, lang);
|
||||
@@ -471,6 +597,8 @@ async fn login_submit(request: Request, session: Session, db: Database) -> cot::
|
||||
lang,
|
||||
error: Some(lang.t().login_error.to_string()),
|
||||
turnstile_site_key,
|
||||
auth_password_enabled: true,
|
||||
auth_sso_enabled: false,
|
||||
}
|
||||
.render()?;
|
||||
html_response(body, lang)
|
||||
@@ -482,6 +610,325 @@ async fn logout(request: Request, session: Session) -> cot::Result<Response> {
|
||||
Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OIDC Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read an OIDC-related setting from the DB, returning empty string if absent.
|
||||
async fn oidc_setting(db: &Database, name: &str) -> cot::Result<String> {
|
||||
let k = name.to_string();
|
||||
Ok(query!(Setting, $key == k)
|
||||
.get(db)
|
||||
.await?
|
||||
.map(|s| s.value)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Fetch the OpenID Connect discovery document and extract a field.
|
||||
async fn oidc_discover(issuer_url: &str, field: &str) -> Option<String> {
|
||||
let url = format!(
|
||||
"{}/.well-known/openid-configuration",
|
||||
issuer_url.trim_end_matches('/')
|
||||
);
|
||||
let resp = reqwest::Client::new().get(&url).send().await.ok()?;
|
||||
let json: serde_json::Value = resp.json().await.ok()?;
|
||||
json.get(field)?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Decode the payload of a JWT (base64url, no signature verification).
|
||||
fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
|
||||
use base64::Engine;
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let payload = parts[1];
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
fn oidc_state_cookie(value: &str, max_age_seconds: u32) -> String {
|
||||
format!(
|
||||
"oidc_state={}; Path=/admin/oidc; HttpOnly; SameSite=Lax; Max-Age={}",
|
||||
value, max_age_seconds,
|
||||
)
|
||||
}
|
||||
|
||||
fn get_cookie(request: &Request, name: &str) -> Option<String> {
|
||||
let prefix = format!("{name}=");
|
||||
request
|
||||
.headers()
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies.split(';').find_map(|part| {
|
||||
let part = part.trim();
|
||||
part.strip_prefix(&prefix).map(|v| v.to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fn oidc_start(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
let lang = detect_lang(&request);
|
||||
let issuer_url = oidc_setting(&db, "oidc_issuer_url").await?;
|
||||
let client_id = oidc_setting(&db, "oidc_client_id").await?;
|
||||
let site_domain = oidc_setting(&db, "site_domain").await?;
|
||||
|
||||
if issuer_url.trim().is_empty() || client_id.trim().is_empty() {
|
||||
return Redirect::new(format!(
|
||||
"/admin/login?lang={}&error=sso_provider",
|
||||
lang.code()
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let authorization_endpoint = match oidc_discover(&issuer_url, "authorization_endpoint").await {
|
||||
Some(ep) => ep,
|
||||
None => {
|
||||
return Redirect::new(format!(
|
||||
"/admin/login?lang={}&error=sso_provider",
|
||||
lang.code()
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let state = rand_token();
|
||||
session.insert(SESSION_OIDC_STATE, state.clone()).await?;
|
||||
|
||||
let redirect_uri = format!("{}/admin/oidc/callback", site_domain.trim_end_matches('/'));
|
||||
|
||||
let redirect_url = format!(
|
||||
"{}?response_type=code&client_id={}&redirect_uri={}&scope=openid+profile&state={}",
|
||||
authorization_endpoint,
|
||||
urlencoding::encode(&client_id),
|
||||
urlencoding::encode(&redirect_uri),
|
||||
urlencoding::encode(&state),
|
||||
);
|
||||
|
||||
let state_cookie = oidc_state_cookie(&state, 600);
|
||||
|
||||
Redirect::new(redirect_url)
|
||||
.into_response()?
|
||||
.with_header("set-cookie", state_cookie)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn oidc_callback(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
let lang = detect_lang(&request);
|
||||
let fail = |code: &str| format!("/admin/login?lang={}&error={}", lang.code(), code);
|
||||
|
||||
// Prefer the server-side session; keep the cookie as a compatibility
|
||||
// fallback for flows started before this code was deployed.
|
||||
let saved_state_from_session = session
|
||||
.get::<String>(SESSION_OIDC_STATE)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let saved_state_from_cookie = get_cookie(&request, "oidc_state");
|
||||
let saved_state = saved_state_from_session
|
||||
.as_deref()
|
||||
.or(saved_state_from_cookie.as_deref())
|
||||
.unwrap_or("");
|
||||
|
||||
// Extract code and state from query string
|
||||
let query_str = request.uri().query().unwrap_or("");
|
||||
let mut code = String::new();
|
||||
let mut state = String::new();
|
||||
for pair in query_str.split('&') {
|
||||
if let Some(v) = pair.strip_prefix("code=") {
|
||||
code = v.to_string();
|
||||
} else if let Some(v) = pair.strip_prefix("state=") {
|
||||
state = v.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if code.is_empty() || state.is_empty() || state != saved_state {
|
||||
tracing::warn!(
|
||||
target: "oidc",
|
||||
has_session_state = saved_state_from_session.is_some(),
|
||||
has_cookie_state = saved_state_from_cookie.is_some(),
|
||||
code_empty = code.is_empty(),
|
||||
state_empty = state.is_empty(),
|
||||
"OIDC state mismatch",
|
||||
);
|
||||
let clear_cookie = oidc_state_cookie("", 0);
|
||||
return Redirect::new(fail("sso"))
|
||||
.into_response()?
|
||||
.with_header("set-cookie", clear_cookie)
|
||||
.into_response();
|
||||
}
|
||||
let _ = session.remove::<String>(SESSION_OIDC_STATE).await;
|
||||
|
||||
let issuer_url = oidc_setting(&db, "oidc_issuer_url").await?;
|
||||
let client_id = oidc_setting(&db, "oidc_client_id").await?;
|
||||
let client_secret = oidc_setting(&db, "oidc_client_secret").await?;
|
||||
let site_domain = oidc_setting(&db, "site_domain").await?;
|
||||
|
||||
// Get token endpoint from discovery
|
||||
let token_endpoint = match oidc_discover(&issuer_url, "token_endpoint").await {
|
||||
Some(ep) => ep,
|
||||
None => {
|
||||
tracing::warn!("OIDC discovery failed for issuer_url={issuer_url:?}");
|
||||
return Redirect::new(fail("sso_provider")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let redirect_uri = format!("{}/admin/oidc/callback", site_domain.trim_end_matches('/'));
|
||||
|
||||
// Exchange code for tokens
|
||||
let token_resp = reqwest::Client::new()
|
||||
.post(&token_endpoint)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", &code),
|
||||
("redirect_uri", &redirect_uri),
|
||||
("client_id", &client_id),
|
||||
("client_secret", &client_secret),
|
||||
])
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let token_json: serde_json::Value = match token_resp {
|
||||
Ok(resp) => match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("OIDC token response parse error: {e}");
|
||||
return Redirect::new(fail("sso_provider")).into_response();
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("OIDC token request failed: {e}");
|
||||
return Redirect::new(fail("sso_provider")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let id_token = match token_json.get("id_token").and_then(|v| v.as_str()) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
tracing::warn!("OIDC no id_token in response: {token_json}");
|
||||
return Redirect::new(fail("sso_provider")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Decode JWT payload (no signature verification — token obtained directly from provider over TLS)
|
||||
let claims = match decode_jwt_payload(id_token) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::warn!("OIDC JWT decode failed");
|
||||
return Redirect::new(fail("sso_provider")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let preferred_username = match claims.get("preferred_username").and_then(|v| v.as_str()) {
|
||||
Some(u) => u.to_string(),
|
||||
None => {
|
||||
tracing::warn!("OIDC no preferred_username in claims: {claims}");
|
||||
return Redirect::new(fail("sso")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let display_name = claims
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Check group membership
|
||||
let allowed_groups = oidc_setting(&db, "oidc_allowed_groups").await?;
|
||||
if !allowed_groups.trim().is_empty() {
|
||||
let required: Vec<&str> = allowed_groups
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let user_groups: Vec<String> = claims
|
||||
.get("groups")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|g| g.as_str())
|
||||
.map(|g| g.trim_start_matches('/').to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let has_group = required
|
||||
.iter()
|
||||
.any(|r| user_groups.iter().any(|ug| ug.eq_ignore_ascii_case(r)));
|
||||
|
||||
if !has_group {
|
||||
tracing::warn!(
|
||||
"OIDC group check failed: user={preferred_username}, user_groups={user_groups:?}, required={required:?}"
|
||||
);
|
||||
return Redirect::new(fail("sso_group")).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
let login = preferred_username.clone();
|
||||
let existing = query!(User, $login == login).get(&db).await?;
|
||||
|
||||
let user = match existing {
|
||||
Some(u) => {
|
||||
tracing::info!(target: "oidc", username = %u.login, "SSO login: existing user");
|
||||
u
|
||||
}
|
||||
None => {
|
||||
tracing::info!(target: "oidc", username = %preferred_username, "SSO login: creating new user");
|
||||
let mut new_user = User {
|
||||
id: Auto::auto(),
|
||||
login: preferred_username.clone(),
|
||||
password_hash: String::new(),
|
||||
display_name: display_name.clone(),
|
||||
telegram_chat_id: None,
|
||||
telegram_notifications: Some(false),
|
||||
status: "active".to_string(),
|
||||
created_at: now_utc(),
|
||||
updated_at: now_utc(),
|
||||
};
|
||||
new_user.save(&db).await?;
|
||||
// Re-query to get the DB-assigned id (Auto::auto() may not be
|
||||
// populated in the struct after save)
|
||||
let login2 = preferred_username.clone();
|
||||
match query!(User, $login == login2).get(&db).await? {
|
||||
Some(u) => {
|
||||
tracing::info!(target: "oidc", username = %u.login, id = ?u.id, "SSO login: new user created and fetched");
|
||||
u
|
||||
}
|
||||
None => {
|
||||
tracing::error!(target: "oidc", username = %preferred_username, "SSO login: user not found after creation");
|
||||
return Redirect::new(fail("sso")).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if user.status != "active" {
|
||||
tracing::warn!(target: "oidc", username = %user.login, status = %user.status, "SSO login: user disabled");
|
||||
return Redirect::new(fail("sso_disabled")).into_response();
|
||||
}
|
||||
|
||||
let session_name = user
|
||||
.display_name
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&user.login)
|
||||
.to_string();
|
||||
|
||||
tracing::info!(target: "oidc", username = %user.login, display_name = %session_name, "SSO login: session established");
|
||||
session.insert(SESSION_USER_ID, user.id.unwrap()).await?;
|
||||
session.insert(SESSION_USER_NAME, session_name).await?;
|
||||
|
||||
// Clear the oidc_state cookie
|
||||
let clear_cookie = oidc_state_cookie("", 0);
|
||||
Redirect::new(format!("/admin/?lang={}", lang.code()))
|
||||
.into_response()?
|
||||
.with_header("set-cookie", clear_cookie)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET Handlers (protected)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -496,8 +943,10 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
|
||||
let tz = crate::tz::load_tz(&db).await;
|
||||
let today = crate::tz::today_in_tz(tz);
|
||||
|
||||
let all_visits = Visit::objects().all(&db).await?;
|
||||
let clients = Client::objects().all(&db).await?;
|
||||
let mut all_visits = Visit::objects().all(&db).await?;
|
||||
all_visits.retain(|v| v.status != "deleted");
|
||||
let mut clients = Client::objects().all(&db).await?;
|
||||
clients.retain(|c| c.status != "deleted");
|
||||
|
||||
let mut today_visits: Vec<TodayVisit> = all_visits
|
||||
.iter()
|
||||
@@ -520,9 +969,7 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
|
||||
|
||||
let mut all_feedbacks: Vec<RecentFeedback> = all_visits
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
v.user_id.primary_key().unwrap() == user_id && v.client_feedback.is_some()
|
||||
})
|
||||
.filter(|v| v.user_id.primary_key().unwrap() == user_id && v.client_feedback.is_some())
|
||||
.map(|v| {
|
||||
let cid: i64 = v.client_id.primary_key().unwrap();
|
||||
let client_name = clients
|
||||
@@ -601,11 +1048,12 @@ async fn clients_page(request: Request, session: Session, db: Database) -> cot::
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let show_all = has_query_flag(&request, "all");
|
||||
let clients = if show_all {
|
||||
let mut clients = if show_all {
|
||||
Client::objects().all(&db).await?
|
||||
} else {
|
||||
query!(Client, $status == "active").all(&db).await?
|
||||
};
|
||||
clients.retain(|c| c.status != "deleted");
|
||||
let body = ClientsTemplate {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
@@ -738,12 +1186,24 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let settings = Setting::objects().all(&db).await?;
|
||||
let auth_password_checked = settings
|
||||
.iter()
|
||||
.find(|s| s.key == "auth_password_enabled")
|
||||
.map(|s| s.value == "true")
|
||||
.unwrap_or(true);
|
||||
let auth_sso_checked = settings
|
||||
.iter()
|
||||
.find(|s| s.key == "auth_sso_enabled")
|
||||
.map(|s| s.value == "true")
|
||||
.unwrap_or(false);
|
||||
let body = SettingsTemplate {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
admin_name: &admin_name,
|
||||
settings,
|
||||
saved: false,
|
||||
auth_password_checked,
|
||||
auth_sso_checked,
|
||||
}
|
||||
.render()?;
|
||||
html_response(body, lang)
|
||||
@@ -819,6 +1279,14 @@ struct SettingsForm {
|
||||
seo_keywords: String,
|
||||
turnstile_site_key: String,
|
||||
turnstile_secret_key: String,
|
||||
oidc_issuer_url: String,
|
||||
oidc_client_id: String,
|
||||
oidc_client_secret: String,
|
||||
oidc_allowed_groups: String,
|
||||
#[serde(default)]
|
||||
auth_password_enabled: Option<String>,
|
||||
#[serde(default)]
|
||||
auth_sso_enabled: Option<String>,
|
||||
}
|
||||
|
||||
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
@@ -837,6 +1305,26 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
||||
("seo_keywords", form.seo_keywords),
|
||||
("turnstile_site_key", form.turnstile_site_key),
|
||||
("turnstile_secret_key", form.turnstile_secret_key),
|
||||
("oidc_issuer_url", form.oidc_issuer_url),
|
||||
("oidc_client_id", form.oidc_client_id),
|
||||
("oidc_client_secret", form.oidc_client_secret),
|
||||
("oidc_allowed_groups", form.oidc_allowed_groups),
|
||||
(
|
||||
"auth_password_enabled",
|
||||
if form.auth_password_enabled.is_some() {
|
||||
"true".to_string()
|
||||
} else {
|
||||
"false".to_string()
|
||||
},
|
||||
),
|
||||
(
|
||||
"auth_sso_enabled",
|
||||
if form.auth_sso_enabled.is_some() {
|
||||
"true".to_string()
|
||||
} else {
|
||||
"false".to_string()
|
||||
},
|
||||
),
|
||||
] {
|
||||
let k = key.to_string();
|
||||
let existing = query!(Setting, $key == k).get(&db).await?;
|
||||
@@ -859,12 +1347,24 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
||||
}
|
||||
|
||||
let settings = Setting::objects().all(&db).await?;
|
||||
let auth_password_checked = settings
|
||||
.iter()
|
||||
.find(|s| s.key == "auth_password_enabled")
|
||||
.map(|s| s.value == "true")
|
||||
.unwrap_or(true);
|
||||
let auth_sso_checked = settings
|
||||
.iter()
|
||||
.find(|s| s.key == "auth_sso_enabled")
|
||||
.map(|s| s.value == "true")
|
||||
.unwrap_or(false);
|
||||
let rendered = SettingsTemplate {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
admin_name: &admin_name,
|
||||
settings,
|
||||
saved: true,
|
||||
auth_password_checked,
|
||||
auth_sso_checked,
|
||||
}
|
||||
.render()?;
|
||||
html_response(rendered, lang)
|
||||
@@ -967,6 +1467,24 @@ async fn client_activate(
|
||||
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
|
||||
}
|
||||
|
||||
async fn client_delete(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
Path(client_id): Path<i64>,
|
||||
) -> cot::Result<Response> {
|
||||
let lang = detect_lang(&request);
|
||||
if let Err(resp) = require_auth(&session, lang).await {
|
||||
return Ok(resp);
|
||||
}
|
||||
if let Some(mut client) = query!(Client, $id == client_id).get(&db).await? {
|
||||
client.status = "deleted".to_string();
|
||||
client.updated_at = now_utc();
|
||||
client.save(&db).await?;
|
||||
}
|
||||
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
|
||||
}
|
||||
|
||||
async fn user_archive(
|
||||
request: Request,
|
||||
session: Session,
|
||||
@@ -1184,12 +1702,18 @@ async fn schedule_events(
|
||||
|
||||
let mut events = Vec::new();
|
||||
for v in &visits {
|
||||
if v.status == "deleted" {
|
||||
continue;
|
||||
}
|
||||
if v.visit_date < start_date || v.visit_date > end_date {
|
||||
continue;
|
||||
}
|
||||
let client_id_val: i64 = v.client_id.primary_key().unwrap();
|
||||
let user_id_val: i64 = v.user_id.primary_key().unwrap();
|
||||
let client = clients.iter().find(|c| c.id.unwrap() == client_id_val);
|
||||
if client.map(|c| c.status.as_str()) == Some("deleted") {
|
||||
continue;
|
||||
}
|
||||
let user = users.iter().find(|u| u.id.unwrap() == user_id_val);
|
||||
let client_name = client.map(|c| c.name.as_str()).unwrap_or("?");
|
||||
let client_phone = client.and_then(|c| c.phone.as_deref()).unwrap_or("");
|
||||
@@ -1313,7 +1837,16 @@ async fn schedule_edit_page(
|
||||
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||
}
|
||||
};
|
||||
let clients = query!(Client, $status == "active").all(&db).await?;
|
||||
if visit.status == "deleted" {
|
||||
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||
}
|
||||
let client_id: i64 = visit.client_id.primary_key().unwrap();
|
||||
let client = match query!(Client, $id == client_id).get(&db).await? {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||
}
|
||||
};
|
||||
let users = query!(User, $status == "active").all(&db).await?;
|
||||
let mut visit_media = Media::objects().all(&db).await?;
|
||||
visit_media.retain(|m| {
|
||||
@@ -1329,7 +1862,7 @@ async fn schedule_edit_page(
|
||||
lang,
|
||||
admin_name: &admin_name,
|
||||
visit,
|
||||
clients,
|
||||
client,
|
||||
users,
|
||||
media: visit_media,
|
||||
}
|
||||
@@ -1339,7 +1872,6 @@ async fn schedule_edit_page(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EditVisitForm {
|
||||
client_id: i64,
|
||||
user_id: i64,
|
||||
visit_date: String,
|
||||
time_start: String,
|
||||
@@ -1360,7 +1892,9 @@ async fn schedule_edit_submit(
|
||||
return Ok(resp);
|
||||
}
|
||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||
visit.client_id = ForeignKey::PrimaryKey(Auto::fixed(form.client_id));
|
||||
if visit.status == "deleted" {
|
||||
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||
}
|
||||
visit.user_id = ForeignKey::PrimaryKey(Auto::fixed(form.user_id));
|
||||
if let Ok(d) = chrono::NaiveDate::parse_from_str(&form.visit_date, "%Y-%m-%d") {
|
||||
visit.visit_date = d;
|
||||
@@ -1386,7 +1920,11 @@ async fn visit_delete(
|
||||
if let Err(resp) = require_auth(&session, lang).await {
|
||||
return Ok(resp);
|
||||
}
|
||||
query!(Visit, $id == visit_id).delete(&db).await?;
|
||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||
visit.status = "deleted".to_string();
|
||||
visit.updated_at = now_utc();
|
||||
visit.save(&db).await?;
|
||||
}
|
||||
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
|
||||
}
|
||||
|
||||
@@ -1401,6 +1939,9 @@ async fn visit_set_done(
|
||||
return Ok(resp);
|
||||
}
|
||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||
if visit.status == "deleted" {
|
||||
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||
}
|
||||
visit.status = "completed".to_string();
|
||||
visit.updated_at = now_utc();
|
||||
visit.save(&db).await?;
|
||||
@@ -1419,6 +1960,9 @@ async fn visit_set_cancel(
|
||||
return Ok(resp);
|
||||
}
|
||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||
if visit.status == "deleted" {
|
||||
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||
}
|
||||
visit.status = "cancelled".to_string();
|
||||
visit.updated_at = now_utc();
|
||||
visit.save(&db).await?;
|
||||
@@ -1448,16 +1992,40 @@ async fn media_page(request: Request, session: Session, db: Database) -> cot::Re
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
let clients_all = Client::objects().all(&db).await?;
|
||||
let visits_all = Visit::objects().all(&db).await?;
|
||||
let mut media_list = Media::objects().all(&db).await?;
|
||||
media_list.retain(|m| m.status == "active");
|
||||
media_list.retain(|m| {
|
||||
if m.status != "active" {
|
||||
return false;
|
||||
}
|
||||
let cid: i64 = m.client_id.primary_key().unwrap();
|
||||
if clients_all
|
||||
.iter()
|
||||
.find(|c| c.id.unwrap() == cid)
|
||||
.map(|c| c.status.as_str())
|
||||
== Some("deleted")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(fk) = &m.visit_id {
|
||||
let vid: i64 = fk.primary_key().unwrap();
|
||||
if visits_all
|
||||
.iter()
|
||||
.find(|v| v.id.unwrap() == vid)
|
||||
.map(|v| v.status.as_str())
|
||||
== Some("deleted")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
if filter_client_id > 0 {
|
||||
media_list.retain(|m| m.client_id.primary_key().unwrap() == filter_client_id);
|
||||
}
|
||||
media_list.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
|
||||
let clients_all = Client::objects().all(&db).await?;
|
||||
let visits_all = Visit::objects().all(&db).await?;
|
||||
|
||||
let items: Vec<MediaItem> = media_list
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
@@ -1511,6 +2079,9 @@ async fn media_upload_page(
|
||||
Some(v) => v,
|
||||
None => return Redirect::new(format!("/admin/?lang={}", lang.code())).into_response(),
|
||||
};
|
||||
if visit.status == "deleted" {
|
||||
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||
}
|
||||
let cid: i64 = visit.client_id.primary_key().unwrap();
|
||||
let client = query!(Client, $id == cid).get(&db).await?;
|
||||
let client_name = client.map(|c| c.name).unwrap_or_default();
|
||||
@@ -1559,6 +2130,9 @@ async fn media_upload_submit(
|
||||
Some(v) => v,
|
||||
None => return Redirect::new(format!("/admin/?lang={}", lang.code())).into_response(),
|
||||
};
|
||||
if visit.status == "deleted" {
|
||||
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||
}
|
||||
let client_id: i64 = visit.client_id.primary_key().unwrap();
|
||||
|
||||
let bytes = request.into_body().into_bytes().await?;
|
||||
@@ -1566,8 +2140,8 @@ async fn media_upload_submit(
|
||||
futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) });
|
||||
let mut multipart = multer::Multipart::new(stream, boundary);
|
||||
|
||||
let upload_dir = format!("uploads/{}/{}", client_id, visit_id);
|
||||
tokio::fs::create_dir_all(&upload_dir)
|
||||
let upload_dir = crate::uploads::media_dir(client_id, visit_id);
|
||||
crate::uploads::create_logical_dir(&upload_dir)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
@@ -1608,7 +2182,6 @@ async fn media_upload_submit(
|
||||
};
|
||||
|
||||
let file_id = uuid::Uuid::new_v4();
|
||||
let file_path = format!("{}/{}.{}", upload_dir, file_id, ext);
|
||||
|
||||
let data = field
|
||||
.bytes()
|
||||
@@ -1617,9 +2190,15 @@ async fn media_upload_submit(
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
tokio::fs::write(&file_path, &data)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let file_path = if file_type == "photo" {
|
||||
save_uploaded_image(&upload_dir, file_id, &ext, &data).await?
|
||||
} else {
|
||||
let path = crate::uploads::join_db_path(&upload_dir, &format!("{file_id}.{ext}"));
|
||||
crate::uploads::write_db_file(&path, &data)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
path
|
||||
};
|
||||
|
||||
saved_files.push((file_path, file_type.to_string()));
|
||||
}
|
||||
@@ -1672,7 +2251,16 @@ async fn media_delete(
|
||||
let file_path = m.file_path.clone();
|
||||
m.status = "archived".to_string();
|
||||
m.save(&db).await?;
|
||||
let _ = tokio::fs::remove_file(&file_path).await;
|
||||
if let Err(err) = crate::uploads::remove_db_file(&file_path).await {
|
||||
tracing::warn!(
|
||||
target: "uploads",
|
||||
media_id,
|
||||
db_path = %file_path,
|
||||
resolved_path = %crate::uploads::resolved_display_path(&file_path),
|
||||
error = %err,
|
||||
"failed to remove uploaded file"
|
||||
);
|
||||
}
|
||||
}
|
||||
let redirect_url = referer
|
||||
.filter(|r| r.contains("/schedule/") && r.contains("/edit"))
|
||||
@@ -1697,7 +2285,7 @@ async fn serve_upload(
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
|
||||
match tokio::fs::read(&media.file_path).await {
|
||||
match crate::uploads::read_db_file(&media.file_path).await {
|
||||
Ok(data) => {
|
||||
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
@@ -1717,7 +2305,17 @@ async fn serve_upload(
|
||||
.insert("content-type", content_type.parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
Err(_) => Html::new("404").into_response(),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
target: "uploads",
|
||||
media_id,
|
||||
db_path = %media.file_path,
|
||||
resolved_path = %crate::uploads::resolved_display_path(&media.file_path),
|
||||
error = %err,
|
||||
"uploaded file is missing or unreadable"
|
||||
);
|
||||
Html::new("404").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1824,15 +2422,12 @@ async fn testimonial_add(
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let upload_dir = "uploads/testimonials";
|
||||
tokio::fs::create_dir_all(upload_dir)
|
||||
let upload_dir = crate::uploads::testimonials_dir();
|
||||
crate::uploads::create_logical_dir(&upload_dir)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let file_id = uuid::Uuid::new_v4();
|
||||
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
|
||||
tokio::fs::write(&path, &data)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let path = save_uploaded_image(&upload_dir, file_id, &ext, &data).await?;
|
||||
image_path = Some(path);
|
||||
}
|
||||
_ => {}
|
||||
@@ -1975,15 +2570,12 @@ async fn testimonial_edit(
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let upload_dir = "uploads/testimonials";
|
||||
tokio::fs::create_dir_all(upload_dir)
|
||||
let upload_dir = crate::uploads::testimonials_dir();
|
||||
crate::uploads::create_logical_dir(&upload_dir)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let file_id = uuid::Uuid::new_v4();
|
||||
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
|
||||
tokio::fs::write(&path, &data)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let path = save_uploaded_image(&upload_dir, file_id, &ext, &data).await?;
|
||||
new_image_path = Some(path);
|
||||
}
|
||||
_ => {}
|
||||
@@ -2024,7 +2616,7 @@ async fn serve_testimonial_image(
|
||||
Some(p) => p.clone(),
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
match tokio::fs::read(&path).await {
|
||||
match crate::uploads::read_db_file(&path).await {
|
||||
Ok(data) => {
|
||||
let content_type = match path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
@@ -2039,7 +2631,17 @@ async fn serve_testimonial_image(
|
||||
.insert("content-type", content_type.parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
Err(_) => Html::new("404").into_response(),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
target: "uploads",
|
||||
testimonial_id = id,
|
||||
db_path = %path,
|
||||
resolved_path = %crate::uploads::resolved_display_path(&path),
|
||||
error = %err,
|
||||
"testimonial image is missing or unreadable"
|
||||
);
|
||||
Html::new("404").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2055,6 +2657,8 @@ pub fn admin_router() -> Router {
|
||||
Route::with_handler_and_name("/logout", logout, "admin-logout"),
|
||||
Route::with_handler_and_name("/setup", setup_page, "admin-setup"),
|
||||
Route::with_handler_and_name("/setup/submit", setup_submit, "admin-setup-submit"),
|
||||
Route::with_handler_and_name("/oidc/start", oidc_start, "admin-oidc-start"),
|
||||
Route::with_handler_and_name("/oidc/callback", oidc_callback, "admin-oidc-callback"),
|
||||
// Protected
|
||||
Route::with_handler_and_name("", admin_index, "admin-index-bare"),
|
||||
Route::with_handler_and_name("/", admin_index, "admin-index"),
|
||||
@@ -2093,6 +2697,11 @@ pub fn admin_router() -> Router {
|
||||
client_activate,
|
||||
"admin-client-activate",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/clients/{client_id}/delete",
|
||||
client_delete,
|
||||
"admin-client-delete",
|
||||
),
|
||||
Route::with_handler_and_name("/schedule", schedule_page, "admin-schedule"),
|
||||
Route::with_handler_and_name("/schedule/new", schedule_new_page, "admin-schedule-new"),
|
||||
Route::with_handler_and_name("/schedule/events", schedule_events, "admin-schedule-events"),
|
||||
|
||||
+62
@@ -103,8 +103,11 @@ pub struct Translations {
|
||||
pub clients_media_link: &'static str,
|
||||
pub clients_add_title: &'static str,
|
||||
pub clients_add_button: &'static str,
|
||||
pub clients_delete: &'static str,
|
||||
pub clients_delete_confirm: &'static str,
|
||||
pub client_status_active: &'static str,
|
||||
pub client_status_archived: &'static str,
|
||||
pub client_status_deleted: &'static str,
|
||||
|
||||
// Users
|
||||
pub users_title: &'static str,
|
||||
@@ -137,6 +140,17 @@ pub struct Translations {
|
||||
pub settings_seo_keywords: &'static str,
|
||||
pub settings_turnstile_site_key: &'static str,
|
||||
pub settings_turnstile_secret_key: &'static str,
|
||||
pub settings_oidc_issuer_url: &'static str,
|
||||
pub settings_oidc_client_id: &'static str,
|
||||
pub settings_oidc_client_secret: &'static str,
|
||||
pub settings_oidc_allowed_groups: &'static str,
|
||||
pub settings_auth_password_enabled: &'static str,
|
||||
pub settings_auth_sso_enabled: &'static str,
|
||||
pub settings_section_advanced: &'static str,
|
||||
pub settings_section_notifications: &'static str,
|
||||
pub settings_section_captcha: &'static str,
|
||||
pub settings_section_oidc: &'static str,
|
||||
pub settings_section_general: &'static str,
|
||||
pub landing_contact_label: &'static str,
|
||||
pub landing_pricing_title: &'static str,
|
||||
|
||||
@@ -151,6 +165,11 @@ pub struct Translations {
|
||||
pub login_title: &'static str,
|
||||
pub login_button: &'static str,
|
||||
pub login_error: &'static str,
|
||||
pub login_sso_button: &'static str,
|
||||
pub login_sso_error: &'static str,
|
||||
pub login_sso_error_group: &'static str,
|
||||
pub login_sso_error_provider: &'static str,
|
||||
pub login_sso_error_user_disabled: &'static str,
|
||||
pub logout: &'static str,
|
||||
pub setup_title: &'static str,
|
||||
pub setup_description: &'static str,
|
||||
@@ -240,6 +259,7 @@ pub struct Translations {
|
||||
pub visit_status_scheduled: &'static str,
|
||||
pub visit_status_completed: &'static str,
|
||||
pub visit_status_cancelled: &'static str,
|
||||
pub visit_status_deleted: &'static str,
|
||||
pub schedule_mark_done: &'static str,
|
||||
pub schedule_cancel: &'static str,
|
||||
pub schedule_edit_title: &'static str,
|
||||
@@ -320,8 +340,11 @@ static RU: Translations = Translations {
|
||||
clients_media_link: "Медиа",
|
||||
clients_add_title: "Добавить клиента",
|
||||
clients_add_button: "Добавить",
|
||||
clients_delete: "Удалить клиента",
|
||||
clients_delete_confirm: "Точно удалить этого клиента?",
|
||||
client_status_active: "Активный",
|
||||
client_status_archived: "Архив",
|
||||
client_status_deleted: "Удалён",
|
||||
|
||||
users_title: "Администраторы",
|
||||
users_login: "Логин",
|
||||
@@ -352,6 +375,17 @@ static RU: Translations = Translations {
|
||||
settings_seo_keywords: "SEO-ключевые слова (через запятую, отображаются на сайте и в мета-теге keywords)",
|
||||
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key (ключ виджета)",
|
||||
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key (секретный ключ)",
|
||||
settings_oidc_issuer_url: "OIDC — URL провайдера (Issuer URL)",
|
||||
settings_oidc_client_id: "OIDC — Client ID",
|
||||
settings_oidc_client_secret: "OIDC — Client Secret",
|
||||
settings_oidc_allowed_groups: "OIDC — Разрешённые группы (через запятую, пусто = все)",
|
||||
settings_auth_password_enabled: "Вход по логину и паролю",
|
||||
settings_auth_sso_enabled: "Вход через SSO (OIDC)",
|
||||
settings_section_advanced: "Расширенные настройки",
|
||||
settings_section_notifications: "Уведомления",
|
||||
settings_section_captcha: "Защита от ботов",
|
||||
settings_section_oidc: "Единый вход (SSO / OIDC)",
|
||||
settings_section_general: "Сайт",
|
||||
landing_contact_label: "Или свяжитесь с нами напрямую",
|
||||
landing_pricing_title: "Стоимость",
|
||||
|
||||
@@ -386,6 +420,11 @@ static RU: Translations = Translations {
|
||||
login_title: "Вход в систему",
|
||||
login_button: "Войти",
|
||||
login_error: "Неверный логин или пароль.",
|
||||
login_sso_button: "Войти через SSO",
|
||||
login_sso_error: "Ошибка SSO-авторизации.",
|
||||
login_sso_error_group: "У вас нет доступа: вы не состоите в разрешённой группе.",
|
||||
login_sso_error_provider: "Не удалось связаться с провайдером авторизации.",
|
||||
login_sso_error_user_disabled: "Ваша учётная запись отключена.",
|
||||
logout: "Выйти",
|
||||
setup_title: "Создание администратора",
|
||||
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
|
||||
@@ -421,6 +460,7 @@ static RU: Translations = Translations {
|
||||
visit_status_scheduled: "Запланирован",
|
||||
visit_status_completed: "Выполнен",
|
||||
visit_status_cancelled: "Отменён",
|
||||
visit_status_deleted: "Удалён",
|
||||
schedule_mark_done: "Выполнен",
|
||||
schedule_cancel: "Отменить",
|
||||
schedule_edit_title: "Редактировать визит",
|
||||
@@ -525,8 +565,11 @@ static EN: Translations = Translations {
|
||||
clients_media_link: "Media",
|
||||
clients_add_title: "Add Client",
|
||||
clients_add_button: "Add",
|
||||
clients_delete: "Delete client",
|
||||
clients_delete_confirm: "Are you sure you want to delete this client?",
|
||||
client_status_active: "Active",
|
||||
client_status_archived: "Archived",
|
||||
client_status_deleted: "Deleted",
|
||||
|
||||
users_title: "Administrators",
|
||||
users_login: "Login",
|
||||
@@ -557,6 +600,17 @@ static EN: Translations = Translations {
|
||||
settings_seo_keywords: "SEO keywords (comma-separated, shown on site and in keywords meta tag)",
|
||||
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key",
|
||||
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key",
|
||||
settings_oidc_issuer_url: "OIDC — Issuer URL",
|
||||
settings_oidc_client_id: "OIDC — Client ID",
|
||||
settings_oidc_client_secret: "OIDC — Client Secret",
|
||||
settings_oidc_allowed_groups: "OIDC — Allowed groups (comma-separated, empty = all)",
|
||||
settings_auth_password_enabled: "Password login",
|
||||
settings_auth_sso_enabled: "SSO login (OIDC)",
|
||||
settings_section_advanced: "Advanced settings",
|
||||
settings_section_notifications: "Notifications",
|
||||
settings_section_captcha: "Bot protection",
|
||||
settings_section_oidc: "Single Sign-On (SSO / OIDC)",
|
||||
settings_section_general: "Site",
|
||||
landing_contact_label: "Or contact us directly",
|
||||
landing_pricing_title: "Pricing",
|
||||
|
||||
@@ -591,6 +645,11 @@ static EN: Translations = Translations {
|
||||
login_title: "Sign In",
|
||||
login_button: "Sign In",
|
||||
login_error: "Invalid login or password.",
|
||||
login_sso_button: "Sign in with SSO",
|
||||
login_sso_error: "SSO authentication failed.",
|
||||
login_sso_error_group: "Access denied: you are not a member of an allowed group.",
|
||||
login_sso_error_provider: "Could not reach the authentication provider.",
|
||||
login_sso_error_user_disabled: "Your account is disabled.",
|
||||
logout: "Sign Out",
|
||||
setup_title: "Create Administrator",
|
||||
setup_description: "There are no administrators yet. Create the first one to get started.",
|
||||
@@ -626,6 +685,7 @@ static EN: Translations = Translations {
|
||||
visit_status_scheduled: "Scheduled",
|
||||
visit_status_completed: "Completed",
|
||||
visit_status_cancelled: "Cancelled",
|
||||
visit_status_deleted: "Deleted",
|
||||
schedule_mark_done: "Done",
|
||||
schedule_cancel: "Cancel",
|
||||
schedule_edit_title: "Edit Visit",
|
||||
@@ -712,6 +772,7 @@ impl Translations {
|
||||
"scheduled" => self.visit_status_scheduled,
|
||||
"completed" => self.visit_status_completed,
|
||||
"cancelled" => self.visit_status_cancelled,
|
||||
"deleted" => self.visit_status_deleted,
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
@@ -720,6 +781,7 @@ impl Translations {
|
||||
match status {
|
||||
"active" => self.client_status_active,
|
||||
"archived" => self.client_status_archived,
|
||||
"deleted" => self.client_status_deleted,
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
+32
-12
@@ -6,13 +6,14 @@ mod public;
|
||||
mod telegram;
|
||||
mod turnstile;
|
||||
mod tz;
|
||||
mod uploads;
|
||||
|
||||
use tracing_subscriber;
|
||||
|
||||
use cot::cli::CliMetadata;
|
||||
use cot::config::{
|
||||
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
|
||||
SessionStoreTypeConfig,
|
||||
DatabaseConfig, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig,
|
||||
SessionStoreConfig, SessionStoreTypeConfig,
|
||||
};
|
||||
use cot::db::migrations::SyncDynMigration;
|
||||
use cot::middleware::SessionMiddleware;
|
||||
@@ -51,24 +52,45 @@ impl App for PublicApp {
|
||||
|
||||
struct PettingProject;
|
||||
|
||||
fn parse_bool_env(name: &str) -> Option<bool> {
|
||||
let value = std::env::var(name).ok()?;
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn debug_enabled(config_name: &str) -> bool {
|
||||
parse_bool_env("WEB_PETTING_DEBUG").unwrap_or_else(|| {
|
||||
matches!(
|
||||
config_name,
|
||||
"dev" | "development" | "debug" | "local" | "test"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn database_url() -> String {
|
||||
std::env::var("WEB_PETTING_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| "postgresql://postgres:postgres@localhost:5432/web_petting".to_string())
|
||||
}
|
||||
|
||||
impl Project for PettingProject {
|
||||
fn cli_metadata(&self) -> CliMetadata {
|
||||
cot::cli::metadata!()
|
||||
}
|
||||
|
||||
fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
|
||||
fn config(&self, config_name: &str) -> cot::Result<ProjectConfig> {
|
||||
Ok(ProjectConfig::builder()
|
||||
.debug(true)
|
||||
.database(
|
||||
DatabaseConfig::builder()
|
||||
.url("sqlite://db.sqlite3?mode=rwc")
|
||||
.build(),
|
||||
)
|
||||
.debug(debug_enabled(config_name))
|
||||
.database(DatabaseConfig::builder().url(database_url()).build())
|
||||
.middlewares(
|
||||
MiddlewareConfig::builder()
|
||||
.session(
|
||||
SessionMiddlewareConfig::builder()
|
||||
.secure(false)
|
||||
.same_site(SameSite::Lax)
|
||||
.store(
|
||||
SessionStoreConfig::builder()
|
||||
.store_type(SessionStoreTypeConfig::Database)
|
||||
@@ -102,8 +124,6 @@ impl Project for PettingProject {
|
||||
fn main() -> impl Project {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.try_init();
|
||||
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
|
||||
PettingProject
|
||||
}
|
||||
|
||||
+2
-14
@@ -1,19 +1,7 @@
|
||||
//! List of migrations for the current app.
|
||||
//!
|
||||
//! Generated by cot CLI 0.6.0 on 2026-04-29 10:36:47+00:00
|
||||
//! Squashed for the PostgreSQL migration on 2026-07-11.
|
||||
|
||||
pub mod m_0001_initial;
|
||||
pub mod m_0002_visit_schedule;
|
||||
pub mod m_0003_visit_feedback;
|
||||
pub mod m_0004_visit_public_notes;
|
||||
pub mod m_0005_testimonials;
|
||||
pub mod m_0006_user_telegram;
|
||||
/// The list of migrations for current app.
|
||||
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
|
||||
&m_0001_initial::Migration,
|
||||
&m_0002_visit_schedule::Migration,
|
||||
&m_0003_visit_feedback::Migration,
|
||||
&m_0004_visit_public_notes::Migration,
|
||||
&m_0005_testimonials::Migration,
|
||||
&m_0006_user_telegram::Migration,
|
||||
];
|
||||
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[&m_0001_initial::Migration];
|
||||
|
||||
+376
-459
@@ -1,7 +1,8 @@
|
||||
//! Generated by cot CLI 0.6.0 on 2026-04-29 10:36:47+00:00
|
||||
//! Initial PostgreSQL schema for the current data model.
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0001_initial";
|
||||
@@ -9,479 +10,395 @@ impl ::cot::db::migrations::Migration for Migration {
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__user"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("login"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("password_hash"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("display_name"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("login"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("password_hash"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("display_name"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("telegram_chat_id"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("telegram_notifications"),
|
||||
<Option<bool> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<bool> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__setting"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("key"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("value"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("key"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("value"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__client"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("name"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("phone"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("email"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("address"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("media_token"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("name"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("phone"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("email"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("address"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("media_token"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("color"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__testimonial"))
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("text"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("author_note"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("image_path"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("sort_order"),
|
||||
<i32 as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<i32 as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<cot::db::ForeignKey<
|
||||
crate::models::Client,
|
||||
> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<cot::db::ForeignKey<
|
||||
crate::models::Client,
|
||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("scheduled_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("duration_minutes"),
|
||||
<Option<i32> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<i32> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("user_id"),
|
||||
<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::User as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::User as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("visit_date"),
|
||||
<chrono::NaiveDate as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDate as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("time_start"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("time_end"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("public_notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_feedback"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__media"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<cot::db::ForeignKey<
|
||||
crate::models::Client,
|
||||
> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<cot::db::ForeignKey<
|
||||
crate::models::Client,
|
||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("visit_id"),
|
||||
<Option<
|
||||
cot::db::ForeignKey<crate::models::Visit>,
|
||||
> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Visit as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Visit as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<Option<
|
||||
cot::db::ForeignKey<crate::models::Visit>,
|
||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("file_path"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("file_type"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("caption"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("visit_id"),
|
||||
<Option<cot::db::ForeignKey<crate::models::Visit>> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Visit as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Visit as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<Option<cot::db::ForeignKey<crate::models::Visit>> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("file_path"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("file_type"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("caption"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__lead"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("name"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("phone"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("email"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("comment"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<Option<
|
||||
cot::db::ForeignKey<crate::models::Client>,
|
||||
> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<Option<
|
||||
cot::db::ForeignKey<crate::models::Client>,
|
||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("name"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("phone"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("email"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("comment"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Client {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
pub name: String,
|
||||
pub phone: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
/// Unique token for the public media page (client views photos/videos here).
|
||||
#[model(unique)]
|
||||
pub media_token: String,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Lead {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
pub name: String,
|
||||
pub phone: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
/// new | in_progress | converted | rejected
|
||||
pub status: String,
|
||||
pub client_id: Option<cot::db::ForeignKey<crate::models::Client>>,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Media {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
pub client_id: cot::db::ForeignKey<crate::models::Client>,
|
||||
pub visit_id: Option<cot::db::ForeignKey<crate::models::Visit>>,
|
||||
pub file_path: String,
|
||||
/// photo | video
|
||||
pub file_type: String,
|
||||
pub caption: Option<String>,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Setting {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
#[model(unique)]
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _User {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
#[model(unique)]
|
||||
pub login: String,
|
||||
pub password_hash: String,
|
||||
pub display_name: Option<String>,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Visit {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
pub client_id: cot::db::ForeignKey<crate::models::Client>,
|
||||
pub scheduled_at: chrono::NaiveDateTime,
|
||||
pub duration_minutes: Option<i32>,
|
||||
pub notes: Option<String>,
|
||||
/// scheduled | completed | cancelled
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Migration: update Visit model for scheduling + add Client.color
|
||||
//! Visit: Remove scheduled_at, duration_minutes; Add user_id, visit_date, time_start, time_end
|
||||
//! Client: Add color
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0002_visit_schedule";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0001_initial",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
// Add color to client (nullable for existing rows)
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__client"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("color"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
// Remove old visit fields
|
||||
::cot::db::migrations::Operation::remove_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("scheduled_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
))
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::remove_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("duration_minutes"),
|
||||
<Option<i32> as ::cot::db::DatabaseField>::TYPE,
|
||||
).set_null(<Option<i32> as ::cot::db::DatabaseField>::NULLABLE))
|
||||
.build(),
|
||||
// Add new fields
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("user_id"),
|
||||
<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::User as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::User as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("visit_date"),
|
||||
<chrono::NaiveDate as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDate as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("time_start"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("time_end"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//! Migration: add client_feedback to Visit
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0003_visit_feedback";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0002_visit_schedule",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] =
|
||||
&[::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_feedback"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
)
|
||||
.build()];
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//! Migration: add public_notes to Visit
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0004_visit_public_notes";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0003_visit_feedback",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] =
|
||||
&[::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("public_notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
)
|
||||
.build()];
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//! Migration: create Testimonial table
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0005_testimonials";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0004_visit_public_notes",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] =
|
||||
&[::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__testimonial"))
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("text"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("author_note"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("image_path"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("sort_order"),
|
||||
<i32 as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<i32 as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build()];
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//! Migration: add telegram_chat_id and telegram_notifications to User
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0006_user_telegram";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0005_testimonials",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__user"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("telegram_chat_id"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__user"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("telegram_notifications"),
|
||||
<Option<bool> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<bool> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
)
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
+3
-1
@@ -43,6 +43,7 @@ pub enum VisitStatus {
|
||||
Scheduled,
|
||||
Completed,
|
||||
Cancelled,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
impl VisitStatus {
|
||||
@@ -51,6 +52,7 @@ impl VisitStatus {
|
||||
Self::Scheduled => "scheduled",
|
||||
Self::Completed => "completed",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::Deleted => "deleted",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,7 +148,7 @@ pub struct Visit {
|
||||
pub public_notes: Option<String>,
|
||||
/// Feedback text from client via portal.
|
||||
pub client_feedback: Option<String>,
|
||||
/// scheduled | completed | cancelled
|
||||
/// scheduled | completed | cancelled | deleted
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
|
||||
+53
-16
@@ -240,7 +240,8 @@ async fn client_portal(
|
||||
.unwrap_or(false);
|
||||
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(c) => c,
|
||||
Some(c) if c.status != "deleted" => c,
|
||||
Some(_) => return Html::new("404").into_response(),
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
|
||||
@@ -249,7 +250,11 @@ async fn client_portal(
|
||||
let today = crate::tz::today_in_tz(tz);
|
||||
|
||||
let mut visits = Visit::objects().all(&db).await?;
|
||||
visits.retain(|v| v.client_id.primary_key().unwrap() == client_id && v.status != "cancelled");
|
||||
visits.retain(|v| {
|
||||
v.client_id.primary_key().unwrap() == client_id
|
||||
&& v.status != "cancelled"
|
||||
&& v.status != "deleted"
|
||||
});
|
||||
visits.sort_by(|a, b| {
|
||||
a.visit_date
|
||||
.cmp(&b.visit_date)
|
||||
@@ -271,6 +276,7 @@ async fn client_portal(
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.status == "active"
|
||||
&& m.client_id.primary_key().unwrap() == client_id
|
||||
&& m.visit_id
|
||||
.as_ref()
|
||||
.map(|fk| fk.primary_key().unwrap() == vid)
|
||||
@@ -327,7 +333,8 @@ async fn submit_feedback(
|
||||
// Verify token matches visit's client
|
||||
let token_clone = token.clone();
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(c) => c,
|
||||
Some(c) if c.status != "deleted" => c,
|
||||
Some(_) => return Html::new("404").into_response(),
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
let client_id = client.id.unwrap();
|
||||
@@ -337,15 +344,15 @@ async fn submit_feedback(
|
||||
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
if !crate::turnstile::verify(&db, form.cf_turnstile_response.as_deref()).await? {
|
||||
return Redirect::new(format!(
|
||||
"/client/{}?lang={}",
|
||||
token_clone,
|
||||
lang.code()
|
||||
))
|
||||
.into_response();
|
||||
return Redirect::new(format!("/client/{}?lang={}", token_clone, lang.code()))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||
if visit.status == "deleted" {
|
||||
return Redirect::new(format!("/client/{}?lang={}", token_clone, lang.code()))
|
||||
.into_response();
|
||||
}
|
||||
if visit.client_id.primary_key().unwrap() == client_id {
|
||||
visit.client_feedback = Some(form.feedback);
|
||||
visit.updated_at = now_utc();
|
||||
@@ -369,7 +376,8 @@ async fn portal_media(
|
||||
) -> cot::Result<Response> {
|
||||
// Verify token
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(c) => c,
|
||||
Some(c) if c.status != "deleted" => c,
|
||||
Some(_) => return Html::new("404").into_response(),
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
let client_id = client.id.unwrap();
|
||||
@@ -378,8 +386,15 @@ async fn portal_media(
|
||||
Some(m) if m.client_id.primary_key().unwrap() == client_id && m.status == "active" => m,
|
||||
_ => return Html::new("404").into_response(),
|
||||
};
|
||||
if let Some(fk) = &media.visit_id {
|
||||
let visit_id: i64 = fk.primary_key().unwrap();
|
||||
match query!(Visit, $id == visit_id).get(&db).await? {
|
||||
Some(v) if v.status != "deleted" => {}
|
||||
_ => return Html::new("404").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
match tokio::fs::read(&media.file_path).await {
|
||||
match crate::uploads::read_db_file(&media.file_path).await {
|
||||
Ok(data) => {
|
||||
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
@@ -399,7 +414,17 @@ async fn portal_media(
|
||||
.insert("content-type", content_type.parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
Err(_) => Html::new("404").into_response(),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
target: "uploads",
|
||||
media_id,
|
||||
db_path = %media.file_path,
|
||||
resolved_path = %crate::uploads::resolved_display_path(&media.file_path),
|
||||
error = %err,
|
||||
"portal media file is missing or unreadable"
|
||||
);
|
||||
Html::new("404").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +441,7 @@ async fn serve_testimonial_image(
|
||||
Some(p) => p.clone(),
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
match tokio::fs::read(&path).await {
|
||||
match crate::uploads::read_db_file(&path).await {
|
||||
Ok(data) => {
|
||||
let content_type = match path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
@@ -433,7 +458,17 @@ async fn serve_testimonial_image(
|
||||
.insert("cache-control", "public, max-age=86400".parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
Err(_) => Html::new("404").into_response(),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
target: "uploads",
|
||||
testimonial_id = id,
|
||||
db_path = %path,
|
||||
resolved_path = %crate::uploads::resolved_display_path(&path),
|
||||
error = %err,
|
||||
"testimonial image is missing or unreadable"
|
||||
);
|
||||
Html::new("404").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,8 +565,10 @@ async fn sitemap_xml(_request: Request, db: Database) -> cot::Result<Response> {
|
||||
domain = site_domain
|
||||
);
|
||||
let mut resp = Response::new(cot::Body::fixed(body.into_bytes()));
|
||||
resp.headers_mut()
|
||||
.insert("content-type", "application/xml; charset=utf-8".parse().unwrap());
|
||||
resp.headers_mut().insert(
|
||||
"content-type",
|
||||
"application/xml; charset=utf-8".parse().unwrap(),
|
||||
);
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DEFAULT_UPLOAD_DIR: &str = "uploads";
|
||||
const UPLOAD_DIR_ENV: &str = "WEB_PETTING_UPLOAD_DIR";
|
||||
|
||||
pub fn media_dir(client_id: i64, visit_id: i64) -> String {
|
||||
format!("{DEFAULT_UPLOAD_DIR}/{client_id}/{visit_id}")
|
||||
}
|
||||
|
||||
pub fn testimonials_dir() -> String {
|
||||
format!("{DEFAULT_UPLOAD_DIR}/testimonials")
|
||||
}
|
||||
|
||||
pub fn join_db_path(dir: &str, filename: &str) -> String {
|
||||
format!("{}/{}", dir.trim_end_matches('/'), filename)
|
||||
}
|
||||
|
||||
pub fn resolve_db_path(db_path: &str) -> PathBuf {
|
||||
let path = PathBuf::from(db_path);
|
||||
if path.is_absolute() {
|
||||
return path;
|
||||
}
|
||||
|
||||
let Some(upload_root) = std::env::var_os(UPLOAD_DIR_ENV) else {
|
||||
return path;
|
||||
};
|
||||
|
||||
let upload_root = PathBuf::from(upload_root);
|
||||
let logical_path = Path::new(db_path);
|
||||
match logical_path.strip_prefix(DEFAULT_UPLOAD_DIR) {
|
||||
Ok(stripped) => upload_root.join(stripped),
|
||||
Err(_) => upload_root.join(logical_path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolved_display_path(db_path: &str) -> String {
|
||||
resolve_db_path(db_path).display().to_string()
|
||||
}
|
||||
|
||||
pub async fn create_logical_dir(db_dir: &str) -> std::io::Result<()> {
|
||||
tokio::fs::create_dir_all(resolve_db_path(db_dir)).await
|
||||
}
|
||||
|
||||
pub async fn write_db_file(db_path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
let physical_path = resolve_db_path(db_path);
|
||||
if let Some(parent) = physical_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
tokio::fs::write(physical_path, data).await
|
||||
}
|
||||
|
||||
pub async fn read_db_file(db_path: &str) -> std::io::Result<Vec<u8>> {
|
||||
tokio::fs::read(resolve_db_path(db_path)).await
|
||||
}
|
||||
|
||||
pub async fn remove_db_file(db_path: &str) -> std::io::Result<()> {
|
||||
tokio::fs::remove_file(resolve_db_path(db_path)).await
|
||||
}
|
||||
@@ -76,11 +76,16 @@
|
||||
<form method="post" action="/admin/clients/{{ client_id }}/archive">
|
||||
<button type="submit" class="button is-warning is-outlined is-fullwidth">{{ t.action_archive }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
{% else if client_status == "archived" %}
|
||||
<form method="post" action="/admin/clients/{{ client_id }}/activate">
|
||||
<button type="submit" class="button is-success is-outlined is-fullwidth">{{ t.action_activate }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if client_status != "deleted" %}
|
||||
<form method="post" action="/admin/clients/{{ client_id }}/delete" onsubmit="return confirm('{{ t.clients_delete_confirm }}');" style="margin-top:0.75rem;">
|
||||
<button type="submit" class="button is-danger is-outlined is-fullwidth">{{ t.clients_delete }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
{% if let Some(err) = error.as_ref() %}
|
||||
<div class="notification is-danger is-light">{{ err }}</div>
|
||||
{% endif %}
|
||||
{% if auth_sso_enabled %}
|
||||
<a href="/admin/oidc/start" class="button is-primary is-fullwidth mt-3">{{ t.login_sso_button }}</a>
|
||||
{% endif %}
|
||||
{% if auth_password_enabled %}
|
||||
{% if auth_sso_enabled %}<hr style="margin:1rem 0;">{% endif %}
|
||||
<form method="post" action="/admin/login/submit">
|
||||
<div class="field">
|
||||
<label class="label">{{ t.users_login }}</label>
|
||||
@@ -45,10 +50,11 @@
|
||||
<div class="control"><input class="input" type="password" name="password" required></div>
|
||||
</div>
|
||||
{% if !turnstile_site_key.is_empty() %}
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-size="compact" style="margin-top:0.75rem;"></div>
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" style="margin-top:0.75rem;"></div>
|
||||
{% endif %}
|
||||
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -27,11 +27,11 @@
|
||||
{% for item in &items %}
|
||||
<div class="media-card">
|
||||
{% if item.media.file_type == "photo" %}
|
||||
<a href="/admin/uploads/{{ item.media.id }}" data-lightbox="photo">
|
||||
<img src="/admin/uploads/{{ item.media.id }}" alt="" loading="lazy">
|
||||
<a href="/admin/uploads/{{ item.media.id.unwrap() }}" data-lightbox="photo">
|
||||
<img src="/admin/uploads/{{ item.media.id.unwrap() }}" alt="" loading="lazy">
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/admin/uploads/{{ item.media.id }}" data-lightbox="video">
|
||||
<a href="/admin/uploads/{{ item.media.id.unwrap() }}" data-lightbox="video">
|
||||
<div class="video-thumb">🎬</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -45,7 +45,7 @@
|
||||
{% if let Some(cap) = item.media.caption.as_deref() %}
|
||||
<div style="font-size:0.82rem;color:#666;margin-top:0.2rem;">{{ cap }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/media/{{ item.media.id }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" style="margin-top:0.3rem;">
|
||||
<form method="post" action="/admin/media/{{ item.media.id.unwrap() }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" style="margin-top:0.3rem;">
|
||||
<button class="button is-small is-danger is-outlined btn-sm">{{ t.media_delete }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -14,15 +14,7 @@
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_client }}</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="client_id" required>
|
||||
{% for c in &clients %}
|
||||
<option value="{{ c.id }}" {% if c.id.unwrap() == visit.client_id.primary_key().unwrap() %}selected{% endif %}>
|
||||
{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<input class="input" type="text" value="{{ client.name }}{% if let Some(p) = client.phone.as_deref() %} ({{ p }}){% endif %}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,17 +113,20 @@
|
||||
{% for m in &media %}
|
||||
<div class="visit-media-item">
|
||||
{% if m.file_type == "photo" %}
|
||||
<a href="/admin/uploads/{{ m.id }}" data-lightbox="photo">
|
||||
<img src="/admin/uploads/{{ m.id }}" alt="" loading="lazy">
|
||||
<a href="/admin/uploads/{{ m.id.unwrap() }}" data-lightbox="photo">
|
||||
<img src="/admin/uploads/{{ m.id.unwrap() }}" alt="" loading="lazy">
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/admin/uploads/{{ m.id }}" data-lightbox="video">
|
||||
<a href="/admin/uploads/{{ m.id.unwrap() }}" data-lightbox="video">
|
||||
<div class="video-thumb-sm">🎬</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if let Some(cap) = m.caption.as_deref() %}
|
||||
<div class="media-cap">{{ cap }}</div>
|
||||
{% endif %}
|
||||
<div class="visit-media-delete">
|
||||
<button type="submit" form="visit-media-delete-{{ m.id.unwrap() }}" class="button is-small is-danger is-outlined">{{ t.media_delete }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -141,6 +136,9 @@
|
||||
|
||||
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
|
||||
</form>
|
||||
{% for m in &media %}
|
||||
<form id="visit-media-delete-{{ m.id.unwrap() }}" method="post" action="/admin/media/{{ m.id.unwrap() }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');"></form>
|
||||
{% endfor %}
|
||||
|
||||
<hr style="margin:1rem 0;">
|
||||
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
|
||||
@@ -254,6 +252,14 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.visit-media-delete {
|
||||
padding: 0.25rem 0.4rem 0.4rem;
|
||||
}
|
||||
.visit-media-delete .button {
|
||||
width: 100%;
|
||||
font-size: 0.68rem;
|
||||
min-height: 1.65rem;
|
||||
}
|
||||
.upload-modal-bg {
|
||||
display: none;
|
||||
position: fixed;
|
||||
|
||||
@@ -14,12 +14,8 @@
|
||||
|
||||
<div class="form-card">
|
||||
<form method="post" action="/admin/settings/save">
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_telegram_bot_token }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="subtitle is-5 mb-3" style="border-bottom:1px solid #eee;padding-bottom:0.5rem;">{{ t.settings_contact_info }}</h2>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_contact_info }}</label>
|
||||
<div class="control">
|
||||
@@ -32,18 +28,6 @@
|
||||
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_site_domain }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_timezone }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_seo_keywords }}</label>
|
||||
<div class="control">
|
||||
@@ -52,23 +36,91 @@
|
||||
placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||
</div>
|
||||
<div id="seoPreview" style="margin-top:0.5rem;padding:0.5rem 0.75rem;background:#fafafa;border:1px solid #eee;border-radius:6px;min-height:2rem;line-height:2;font-size:0.85rem;display:none;"></div>
|
||||
<p style="font-size:0.78rem;color:#aaa;margin-top:0.3rem;">Каждая фраза между запятыми — отдельное ключевое слово</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_turnstile_site_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_turnstile_secret_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<details style="margin-top:1.5rem;">
|
||||
<summary class="subtitle is-5 mb-3" style="cursor:pointer;border-bottom:1px solid #eee;padding-bottom:0.5rem;">
|
||||
{{ t.settings_section_advanced }}
|
||||
</summary>
|
||||
|
||||
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
|
||||
<div style="margin-top:1rem;">
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey">{{ t.settings_section_general }}</h3>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_site_domain }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_timezone }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_notifications }}</h3>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_telegram_bot_token }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_captcha }}</h3>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_turnstile_site_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_turnstile_secret_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_oidc }}</h3>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_oidc_issuer_url }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_oidc_client_id }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="oidc_client_id" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_oidc_client_secret }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="oidc_client_secret" value="{% for s in &settings %}{% if s.key == "oidc_client_secret" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_oidc_allowed_groups }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="oidc_allowed_groups" placeholder="admins, web-petting" value="{% for s in &settings %}{% if s.key == "oidc_allowed_groups" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
|
||||
{{ t.settings_auth_password_enabled }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
|
||||
{{ t.settings_auth_sso_enabled }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button type="submit" class="button is-primary" style="margin-top:1.5rem;">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -147,11 +147,11 @@
|
||||
<div class="media-row">
|
||||
{% for m in &pv.media %}
|
||||
{% if m.file_type == "photo" %}
|
||||
<a href="/client/{{ client.media_token }}/media/{{ m.id }}" data-lightbox="photo">
|
||||
<img src="/client/{{ client.media_token }}/media/{{ m.id }}" alt="" loading="lazy">
|
||||
<a href="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" data-lightbox="photo">
|
||||
<img src="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" alt="" loading="lazy">
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/client/{{ client.media_token }}/media/{{ m.id }}" data-lightbox="video">
|
||||
<a href="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" data-lightbox="video">
|
||||
<div class="vid-thumb">🎬</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user