9 Commits
Author SHA1 Message Date
Ultradesu 2d43600066 Fixed notifications VAPID
Build and Publish / Build and Publish Docker Image (push) Successful in 5m11s
2026-08-08 11:45:29 +01:00
Ultradesu d6e6075469 Fixed notifications VAPID
Build and Publish / Build and Publish Docker Image (push) Successful in 1m26s
2026-08-08 11:21:23 +01:00
Ultradesu 289b1e8d37 Added notifications, image preview generator
Build and Publish / Build and Publish Docker Image (push) Successful in 5m44s
2026-08-08 11:03:19 +01:00
Ultradesu c4823b7e64 Added notifications, image preview generator 2026-08-08 11:02:56 +01:00
Ultradesu f7a89b431d Added video preview and http range support
Build and Publish / Build and Publish Docker Image (push) Successful in 5m11s
2026-08-08 10:19:47 +01:00
ab 1bee7a7940 Fixed media upload
Build and Publish / Build and Publish Docker Image (push) Successful in 1m39s
2026-07-11 15:20:21 +03:00
ab 1bd3e17672 Moved to PSQL.
Build and Publish / Build and Publish Docker Image (push) Successful in 1m42s
2026-07-11 14:11:31 +03:00
Ultradesu 91ca486e64 Added user/visits 'deleted' status, hide it in UI
Build and Publish / Build and Publish Docker Image (push) Successful in 1m22s
2026-06-04 13:41:18 +03:00
Ultradesu 2389bca42b Fixed image transcoding. Paying attention to EXIF orientation data
Build and Publish / Build and Publish Docker Image (push) Successful in 1m24s
2026-06-04 13:08:34 +03:00
26 changed files with 3186 additions and 971 deletions
+60
View File
@@ -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.)
+26 -20
View File
@@ -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
+722 -80
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,10 +1,11 @@
[package]
name = "web-petting"
version = "0.1.13"
version = "1.0.3"
edition = "2024"
default-run = "web-petting"
[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"] }
@@ -15,9 +16,11 @@ serde_json = "1"
multer = "3"
futures = "0.3"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
tokio = { version = "1", features = ["fs"] }
tokio = { version = "1", features = ["fs", "rt-multi-thread"] }
uuid = { version = "1", features = ["v4"] }
base64 = "0.22"
urlencoding = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
web-push-native = "0.5"
async-trait = "0.1"
+1
View File
@@ -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
+407 -74
View File
@@ -6,6 +6,7 @@ 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;
@@ -14,11 +15,12 @@ use serde::Deserialize;
use std::io::Cursor;
use crate::i18n::{Lang, Translations};
use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit};
use crate::models::{Client, Lead, Media, PushSubscription, Setting, Testimonial, User, Visit};
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;
@@ -117,9 +119,16 @@ fn transcode_uploaded_image(data: &[u8], ext: &str) -> cot::Result<Option<Vec<u8
return Ok(None);
};
let image = ImageReader::with_format(Cursor::new(data), format)
.decode()
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,
@@ -142,14 +151,17 @@ async fn save_uploaded_image(
data: &[u8],
) -> cot::Result<String> {
if let Some(encoded) = transcode_uploaded_image(data, ext)? {
let path = format!("{}/{}.jpg", upload_dir, file_id);
tokio::fs::write(&path, &encoded)
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()))?;
crate::uploads::ensure_thumbnail(&path)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(path)
} else {
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
tokio::fs::write(&path, data)
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)
@@ -327,6 +339,54 @@ struct SettingsTemplate<'a> {
saved: bool,
auth_password_checked: bool,
auth_sso_checked: bool,
client_notifications_checked: bool,
push_subscribers: Vec<PushSubscriberItem>,
}
#[derive(Debug)]
struct PushSubscriberItem {
client_name: String,
device_count: usize,
languages: String,
last_updated: String,
}
async fn load_push_subscribers(db: &Database) -> cot::Result<Vec<PushSubscriberItem>> {
let clients = Client::objects().all(db).await?;
let subscriptions = PushSubscription::objects().all(db).await?;
let mut items = Vec::new();
for client in clients {
let client_id = client.id.unwrap();
let active: Vec<_> = subscriptions
.iter()
.filter(|subscription| {
subscription.status == "active"
&& subscription.client_id.primary_key().unwrap() == client_id
})
.collect();
if active.is_empty() {
continue;
}
let mut languages: Vec<&str> = active
.iter()
.map(|subscription| subscription.language.as_str())
.collect();
languages.sort_unstable();
languages.dedup();
let last_updated = active
.iter()
.map(|subscription| subscription.updated_at)
.max()
.unwrap();
items.push(PushSubscriberItem {
client_name: client.name,
device_count: active.len(),
languages: languages.join(", "),
last_updated: last_updated.format("%d.%m.%Y %H:%M").to_string(),
});
}
items.sort_by(|a, b| a.client_name.cmp(&b.client_name));
Ok(items)
}
#[derive(Debug, Template)]
@@ -357,7 +417,7 @@ struct ScheduleEditTemplate<'a> {
lang: Lang,
admin_name: &'a str,
visit: Visit,
clients: Vec<Client>,
client: Client,
users: Vec<User>,
media: Vec<Media>,
}
@@ -378,6 +438,8 @@ struct MediaTemplate<'a> {
items: Vec<MediaItem>,
clients: Vec<Client>,
filter_client_id: i64,
page: usize,
total_pages: usize,
}
#[derive(Debug, Template)]
@@ -640,7 +702,28 @@ fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
serde_json::from_slice(&bytes).ok()
}
async fn oidc_start(request: Request, db: Database) -> cot::Result<Response> {
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?;
@@ -666,6 +749,7 @@ async fn oidc_start(request: Request, db: Database) -> cot::Result<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('/'));
@@ -677,10 +761,7 @@ async fn oidc_start(request: Request, db: Database) -> cot::Result<Response> {
urlencoding::encode(&state),
);
let state_cookie = format!(
"oidc_state={}; Path=/admin/oidc; HttpOnly; Secure; SameSite=Lax; Max-Age=600",
state,
);
let state_cookie = oidc_state_cookie(&state, 600);
Redirect::new(redirect_url)
.into_response()?
@@ -692,18 +773,18 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
let lang = detect_lang(&request);
let fail = |code: &str| format!("/admin/login?lang={}&error={}", lang.code(), code);
// Read saved state from cookie
let saved_state = 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("oidc_state=").map(|v| v.to_string())
})
})
.unwrap_or_default();
// 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("");
@@ -719,12 +800,20 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
if code.is_empty() || state.is_empty() || state != saved_state {
tracing::warn!(
"OIDC state mismatch: state={state:?}, saved={saved_state:?}, code_empty={}, state_empty={}",
code.is_empty(),
state.is_empty(),
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",
);
return Redirect::new(fail("sso")).into_response();
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?;
@@ -886,7 +975,7 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
session.insert(SESSION_USER_NAME, session_name).await?;
// Clear the oidc_state cookie
let clear_cookie = "oidc_state=; Path=/admin/oidc; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
let clear_cookie = oidc_state_cookie("", 0);
Redirect::new(format!("/admin/?lang={}", lang.code()))
.into_response()?
.with_header("set-cookie", clear_cookie)
@@ -907,8 +996,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()
@@ -1010,11 +1101,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,
@@ -1157,6 +1249,11 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
.find(|s| s.key == "auth_sso_enabled")
.map(|s| s.value == "true")
.unwrap_or(false);
let client_notifications_checked = settings
.iter()
.find(|s| s.key == "client_notifications_enabled")
.map(|s| s.value == "true")
.unwrap_or(false);
let body = SettingsTemplate {
t: lang.t(),
lang,
@@ -1165,6 +1262,8 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
saved: false,
auth_password_checked,
auth_sso_checked,
client_notifications_checked,
push_subscribers: load_push_subscribers(&db).await?,
}
.render()?;
html_response(body, lang)
@@ -1244,10 +1343,15 @@ struct SettingsForm {
oidc_client_id: String,
oidc_client_secret: String,
oidc_allowed_groups: String,
vapid_public_key: String,
vapid_private_key: String,
vapid_subject: String,
#[serde(default)]
auth_password_enabled: Option<String>,
#[serde(default)]
auth_sso_enabled: Option<String>,
#[serde(default)]
client_notifications_enabled: Option<String>,
}
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
@@ -1257,6 +1361,20 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
Err(resp) => return Ok(resp),
};
let old_settings = Setting::objects().all(&db).await?;
let old_value = |key: &str| {
old_settings
.iter()
.find(|setting| setting.key == key)
.map(|setting| setting.value.as_str())
.unwrap_or("")
};
let had_vapid_keys = !old_value("vapid_public_key").trim().is_empty()
|| !old_value("vapid_private_key").trim().is_empty();
let vapid_keys_changed = had_vapid_keys
&& (old_value("vapid_public_key").trim() != form.vapid_public_key.trim()
|| old_value("vapid_private_key").trim() != form.vapid_private_key.trim());
for (key, value) in [
("telegram_bot_token", form.telegram_bot_token),
("contact_info", form.contact_info),
@@ -1270,6 +1388,9 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
("oidc_client_id", form.oidc_client_id),
("oidc_client_secret", form.oidc_client_secret),
("oidc_allowed_groups", form.oidc_allowed_groups),
("vapid_public_key", form.vapid_public_key),
("vapid_private_key", form.vapid_private_key),
("vapid_subject", form.vapid_subject),
(
"auth_password_enabled",
if form.auth_password_enabled.is_some() {
@@ -1286,6 +1407,14 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
"false".to_string()
},
),
(
"client_notifications_enabled",
if form.client_notifications_enabled.is_some() {
"true".to_string()
} else {
"false".to_string()
},
),
] {
let k = key.to_string();
let existing = query!(Setting, $key == k).get(&db).await?;
@@ -1307,6 +1436,16 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
}
}
if vapid_keys_changed {
for mut subscription in PushSubscription::objects().all(&db).await? {
if subscription.status == "active" {
subscription.status = "archived".to_string();
subscription.updated_at = now_utc();
subscription.save(&db).await?;
}
}
}
let settings = Setting::objects().all(&db).await?;
let auth_password_checked = settings
.iter()
@@ -1318,6 +1457,11 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
.find(|s| s.key == "auth_sso_enabled")
.map(|s| s.value == "true")
.unwrap_or(false);
let client_notifications_checked = settings
.iter()
.find(|s| s.key == "client_notifications_enabled")
.map(|s| s.value == "true")
.unwrap_or(false);
let rendered = SettingsTemplate {
t: lang.t(),
lang,
@@ -1326,6 +1470,8 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
saved: true,
auth_password_checked,
auth_sso_checked,
client_notifications_checked,
push_subscribers: load_push_subscribers(&db).await?,
}
.render()?;
html_response(rendered, lang)
@@ -1428,6 +1574,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,
@@ -1645,12 +1809,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("");
@@ -1774,7 +1944,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| {
@@ -1790,7 +1969,7 @@ async fn schedule_edit_page(
lang,
admin_name: &admin_name,
visit,
clients,
client,
users,
media: visit_media,
}
@@ -1800,7 +1979,6 @@ async fn schedule_edit_page(
#[derive(Deserialize)]
struct EditVisitForm {
client_id: i64,
user_id: i64,
visit_date: String,
time_start: String,
@@ -1821,7 +1999,10 @@ 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();
}
let became_completed = visit.status != "completed" && form.status == "completed";
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;
@@ -1833,6 +2014,9 @@ async fn schedule_edit_submit(
visit.public_notes = form.public_notes.filter(|s| !s.trim().is_empty());
visit.updated_at = now_utc();
visit.save(&db).await?;
if became_completed {
crate::web_push::notify_visit_completed(&db, &visit).await;
}
}
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
}
@@ -1847,7 +2031,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()
}
@@ -1862,9 +2050,16 @@ 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();
}
let became_completed = visit.status != "completed";
visit.status = "completed".to_string();
visit.updated_at = now_utc();
visit.save(&db).await?;
if became_completed {
crate::web_push::notify_visit_completed(&db, &visit).await;
}
}
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
}
@@ -1880,6 +2075,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?;
@@ -1892,6 +2090,7 @@ async fn visit_set_cancel(
// ---------------------------------------------------------------------------
async fn media_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
const MEDIA_PER_PAGE: usize = 24;
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
@@ -1908,19 +2107,60 @@ async fn media_page(request: Request, session: Session, db: Database) -> cot::Re
.and_then(|v| v.parse().ok())
})
.unwrap_or(0);
let requested_page = request
.uri()
.query()
.and_then(|query| {
query
.split('&')
.find_map(|part| part.strip_prefix("page="))
.and_then(|value| value.parse::<usize>().ok())
})
.unwrap_or(1)
.max(1);
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 total_pages = media_list.len().div_ceil(MEDIA_PER_PAGE).max(1);
let page = requested_page.min(total_pages);
let page_start = (page - 1) * MEDIA_PER_PAGE;
let items: Vec<MediaItem> = media_list
.into_iter()
.skip(page_start)
.take(MEDIA_PER_PAGE)
.map(|m| {
let cid: i64 = m.client_id.primary_key().unwrap();
let client = clients_all.iter().find(|c| c.id.unwrap() == cid);
@@ -1952,6 +2192,8 @@ async fn media_page(request: Request, session: Session, db: Database) -> cot::Re
items,
clients: active_clients,
filter_client_id,
page,
total_pages,
}
.render()?;
html_response(body, lang)
@@ -1972,6 +2214,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();
@@ -2020,6 +2265,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?;
@@ -2027,8 +2275,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()))?;
@@ -2080,8 +2328,8 @@ async fn media_upload_submit(
let file_path = if file_type == "photo" {
save_uploaded_image(&upload_dir, file_id, &ext, &data).await?
} else {
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
tokio::fs::write(&path, &data)
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
@@ -2138,7 +2386,22 @@ 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 thumbnail_path = crate::uploads::thumbnail_db_path(&file_path);
if let Err(error) = crate::uploads::remove_db_file(&thumbnail_path).await {
if error.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(%error, %thumbnail_path, "failed to remove media thumbnail");
}
}
}
let redirect_url = referer
.filter(|r| r.contains("/schedule/") && r.contains("/edit"))
@@ -2146,6 +2409,44 @@ async fn media_delete(
Redirect::new(redirect_url).into_response()
}
async fn serve_upload_thumbnail(
request: Request,
session: Session,
db: Database,
Path(media_id): Path<i64>,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
if require_auth(&session, lang).await.is_err() {
return Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response();
}
let media = match query!(Media, $id == media_id).get(&db).await? {
Some(media) if media.status == "active" && media.file_type == "photo" => media,
_ => return Html::new("404").into_response(),
};
match crate::uploads::ensure_thumbnail(&media.file_path).await {
Ok(path) => {
let mut response = crate::uploads::ranged_file_response(&path, "image/jpeg", None)
.await
.map_err(|error| cot::Error::internal(error.to_string()))?;
response.headers_mut().insert(
"cache-control",
"private, max-age=31536000, immutable".parse().unwrap(),
);
Ok(response)
}
Err(error) => {
tracing::warn!(media_id, %error, "failed to create media thumbnail");
crate::uploads::ranged_file_response(
&media.file_path,
crate::uploads::content_type_for_path(&media.file_path),
None,
)
.await
.map_err(|error| cot::Error::internal(error.to_string()))
}
}
}
/// Serve uploaded files by media ID.
async fn serve_upload(
request: Request,
@@ -2163,27 +2464,39 @@ async fn serve_upload(
None => return Html::new("404").into_response(),
};
match tokio::fs::read(&media.file_path).await {
Ok(data) => {
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"heic" | "heif" => "image/heic",
"webp" => "image/webp",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"webm" => "video/webm",
_ => "application/octet-stream",
};
let body = cot::Body::fixed(data);
let mut resp = Response::new(body);
resp.headers_mut()
.insert("content-type", content_type.parse().unwrap());
Ok(resp)
let range = request
.headers()
.get("range")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
match {
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"heic" | "heif" => "image/heic",
"webp" => "image/webp",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"webm" => "video/webm",
_ => "application/octet-stream",
};
crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await
} {
Ok(response) => Ok(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()
}
Err(_) => Html::new("404").into_response(),
}
}
@@ -2290,12 +2603,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 = save_uploaded_image(upload_dir, file_id, &ext, &data).await?;
let path = save_uploaded_image(&upload_dir, file_id, &ext, &data).await?;
image_path = Some(path);
}
_ => {}
@@ -2438,12 +2751,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 = save_uploaded_image(upload_dir, file_id, &ext, &data).await?;
let path = save_uploaded_image(&upload_dir, file_id, &ext, &data).await?;
new_image_path = Some(path);
}
_ => {}
@@ -2484,7 +2797,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",
@@ -2499,7 +2812,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()
}
}
}
@@ -2555,6 +2878,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"),
@@ -2618,6 +2946,11 @@ pub fn admin_router() -> Router {
"admin-media-delete",
),
Route::with_handler_and_name("/uploads/{media_id}", serve_upload, "admin-uploads"),
Route::with_handler_and_name(
"/uploads/{media_id}/thumbnail",
serve_upload_thumbnail,
"admin-upload-thumbnail",
),
Route::with_handler_and_name("/testimonials", testimonials_page, "admin-testimonials"),
Route::with_handler_and_name(
"/testimonials/add",
+20
View File
@@ -0,0 +1,20 @@
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use web_push_native::jwt_simple::algorithms::ES256KeyPair;
use web_push_native::p256::SecretKey;
use web_push_native::p256::elliptic_curve::sec1::ToEncodedPoint;
fn main() {
let key_pair = ES256KeyPair::generate();
let private = key_pair.to_bytes();
let public = SecretKey::from_slice(&private)
.expect("generated key must be valid")
.public_key()
.to_encoded_point(false);
println!("VAPID private key (copy only the next line):");
println!("{}", URL_SAFE_NO_PAD.encode(&private));
println!("\nVAPID public key (copy only the next line):");
println!("{}", URL_SAFE_NO_PAD.encode(public.as_bytes()));
println!("\nVAPID subject:");
println!("mailto:admin@example.com");
}
+89
View File
@@ -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,
@@ -148,6 +151,19 @@ pub struct Translations {
pub settings_section_captcha: &'static str,
pub settings_section_oidc: &'static str,
pub settings_section_general: &'static str,
pub settings_client_notifications_enabled: &'static str,
pub settings_client_notifications_help: &'static str,
pub settings_vapid_public_key: &'static str,
pub settings_vapid_private_key: &'static str,
pub settings_vapid_subject: &'static str,
pub settings_vapid_warning: &'static str,
pub settings_vapid_generate: &'static str,
pub settings_push_subscribers: &'static str,
pub settings_push_no_subscribers: &'static str,
pub settings_push_client: &'static str,
pub settings_push_devices: &'static str,
pub settings_push_language: &'static str,
pub settings_push_updated: &'static str,
pub landing_contact_label: &'static str,
pub landing_pricing_title: &'static str,
@@ -256,6 +272,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,
@@ -288,6 +305,18 @@ pub struct Translations {
pub portal_feedback_submit: &'static str,
pub portal_feedback_thanks: &'static str,
pub portal_link: &'static str,
pub portal_notifications: &'static str,
pub portal_notifications_text: &'static str,
pub portal_notifications_enable: &'static str,
pub portal_notifications_disable: &'static str,
pub portal_notifications_denied: &'static str,
pub portal_notifications_active: &'static str,
pub portal_notifications_error: &'static str,
pub portal_notifications_unsupported: &'static str,
pub portal_calendar: &'static str,
pub portal_future_visit: &'static str,
pub portal_previous: &'static str,
pub portal_next: &'static str,
// Common
pub no_value: &'static str,
@@ -336,8 +365,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: "Логин",
@@ -379,6 +411,19 @@ static RU: Translations = Translations {
settings_section_captcha: "Защита от ботов",
settings_section_oidc: "Единый вход (SSO / OIDC)",
settings_section_general: "Сайт",
settings_client_notifications_enabled: "Разрешить клиентам браузерные уведомления",
settings_client_notifications_help: "Показывает клиентам настройку уведомлений о завершённых визитах.",
settings_vapid_public_key: "VAPID — публичный ключ",
settings_vapid_private_key: "VAPID — приватный ключ",
settings_vapid_subject: "VAPID — контакт (например mailto:admin@example.com)",
settings_vapid_warning: "Важно: смена пары VAPID-ключей сделает недействительными подписки всех клиентов. Им потребуется снова включить уведомления.",
settings_vapid_generate: "Для генерации новой пары ключей выполните:",
settings_push_subscribers: "Активные подписки клиентов",
settings_push_no_subscribers: "Активных подписок пока нет.",
settings_push_client: "Клиент",
settings_push_devices: "Устройства",
settings_push_language: "Язык",
settings_push_updated: "Обновлено",
landing_contact_label: "Или свяжитесь с нами напрямую",
landing_pricing_title: "Стоимость",
@@ -409,6 +454,18 @@ static RU: Translations = Translations {
portal_feedback_submit: "Отправить",
portal_feedback_thanks: "Спасибо за отзыв!",
portal_link: "Ссылка клиента",
portal_notifications: "Уведомления",
portal_notifications_text: "Получайте уведомления о завершённых визитах, даже когда страница закрыта. На iPhone сначала добавьте сайт на экран «Домой» и откройте его оттуда.",
portal_notifications_enable: "Включить уведомления",
portal_notifications_disable: "Отключить уведомления",
portal_notifications_denied: "Уведомления заблокированы в настройках браузера.",
portal_notifications_active: "Уведомления подключены на этом устройстве.",
portal_notifications_error: "Не удалось сохранить подписку. Обновите страницу и попробуйте ещё раз.",
portal_notifications_unsupported: "Этот браузер не поддерживает фоновые уведомления.",
portal_calendar: "Календарь визитов",
portal_future_visit: "Будущий визит",
portal_previous: "Назад",
portal_next: "Далее",
login_title: "Вход в систему",
login_button: "Войти",
@@ -453,6 +510,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: "Редактировать визит",
@@ -557,8 +615,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",
@@ -600,6 +661,19 @@ static EN: Translations = Translations {
settings_section_captcha: "Bot protection",
settings_section_oidc: "Single Sign-On (SSO / OIDC)",
settings_section_general: "Site",
settings_client_notifications_enabled: "Allow client browser notifications",
settings_client_notifications_help: "Shows clients the completed-visit notification setting.",
settings_vapid_public_key: "VAPID public key",
settings_vapid_private_key: "VAPID private key",
settings_vapid_subject: "VAPID contact (for example mailto:admin@example.com)",
settings_vapid_warning: "Important: changing the VAPID key pair invalidates every client subscription. Clients will need to enable notifications again.",
settings_vapid_generate: "To generate a new key pair, run:",
settings_push_subscribers: "Active client subscriptions",
settings_push_no_subscribers: "There are no active subscriptions yet.",
settings_push_client: "Client",
settings_push_devices: "Devices",
settings_push_language: "Language",
settings_push_updated: "Updated",
landing_contact_label: "Or contact us directly",
landing_pricing_title: "Pricing",
@@ -630,6 +704,18 @@ static EN: Translations = Translations {
portal_feedback_submit: "Submit",
portal_feedback_thanks: "Thank you for your feedback!",
portal_link: "Client link",
portal_notifications: "Notifications",
portal_notifications_text: "Receive completed-visit notifications even when this page is closed. On iPhone, first add this site to the Home Screen and open it from there.",
portal_notifications_enable: "Enable notifications",
portal_notifications_disable: "Disable notifications",
portal_notifications_denied: "Notifications are blocked in your browser settings.",
portal_notifications_active: "Notifications are enabled on this device.",
portal_notifications_error: "The subscription could not be saved. Reload the page and try again.",
portal_notifications_unsupported: "This browser does not support background notifications.",
portal_calendar: "Visit calendar",
portal_future_visit: "Future visit",
portal_previous: "Previous",
portal_next: "Next",
login_title: "Sign In",
login_button: "Sign In",
@@ -674,6 +760,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",
@@ -760,6 +847,7 @@ impl Translations {
"scheduled" => self.visit_status_scheduled,
"completed" => self.visit_status_completed,
"cancelled" => self.visit_status_cancelled,
"deleted" => self.visit_status_deleted,
_ => "?",
}
}
@@ -768,6 +856,7 @@ impl Translations {
match status {
"active" => self.client_status_active,
"archived" => self.client_status_archived,
"deleted" => self.client_status_deleted,
_ => "?",
}
}
+68 -14
View File
@@ -6,6 +6,8 @@ mod public;
mod telegram;
mod turnstile;
mod tz;
mod uploads;
mod web_push;
use tracing_subscriber;
@@ -16,7 +18,7 @@ use cot::config::{
};
use cot::db::migrations::SyncDynMigration;
use cot::middleware::SessionMiddleware;
use cot::project::{MiddlewareContext, RegisterAppsContext, RootHandler};
use cot::project::{MiddlewareContext, ProjectContext, RegisterAppsContext, RootHandler};
use cot::router::Router;
use cot::session::db::SessionApp;
use cot::{App, AppBuilder, Project};
@@ -39,11 +41,17 @@ impl App for PettingApp {
struct PublicApp;
#[async_trait::async_trait]
impl App for PublicApp {
fn name(&self) -> &'static str {
"public"
}
async fn init(&self, context: &mut ProjectContext) -> cot::Result<()> {
web_push::initialize(context.database()).await;
Ok(())
}
fn router(&self) -> Router {
public::public_router()
}
@@ -51,19 +59,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(|_| {
eprintln!(
"WEB_PETTING_DATABASE_URL and DATABASE_URL are not set; using the local default \
postgresql://postgres:postgres@localhost:5432/web_petting"
);
"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(
@@ -99,12 +133,32 @@ impl Project for PettingProject {
}
}
#[cot::main]
fn main() -> impl Project {
fn main() {
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();
PettingProject
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("failed to build Tokio runtime");
if let Err(error) = runtime.block_on(cot::run_cli(PettingProject)) {
let message = error.to_string();
let details = format!("{error:?}");
eprintln!("Failed to start web-petting: {message}\nDetails: {details}");
if details.contains("28P01") || details.contains("password authentication failed") {
eprintln!(
"\nPostgreSQL rejected the configured username or password.\n\
Set the connection string before starting the application, for example:\n\n \
WEB_PETTING_DATABASE_URL='postgresql://postgres:YOUR_PASSWORD@localhost:5432/web_petting' cargo run\n\n\
WEB_PETTING_DATABASE_URL takes priority over DATABASE_URL.\n\
Check the current value with: printenv WEB_PETTING_DATABASE_URL"
);
} else if message.to_ascii_lowercase().contains("database") {
eprintln!(
"\nConfigure PostgreSQL with WEB_PETTING_DATABASE_URL or DATABASE_URL.\n\
Example:\n\n WEB_PETTING_DATABASE_URL='postgresql://postgres:YOUR_PASSWORD@localhost:5432/web_petting' cargo run"
);
}
std::process::exit(1);
}
}
+3 -11
View File
@@ -1,19 +1,11 @@
//! 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;
pub mod m_0002_push_subscription;
/// 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,
&m_0002_push_subscription::Migration,
];
+376 -459
View File
@@ -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,
}
@@ -0,0 +1,67 @@
//! Store browser Web Push subscriptions for client devices.
#[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_push_subscription";
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
&[::cot::db::migrations::MigrationDependency::migration(
"web-petting",
"m_0001_initial",
)];
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
::cot::db::migrations::Operation::create_model()
.table_name(::cot::db::Identifier::new("web_petting__push_subscription"))
.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("endpoint"),
<String as ::cot::db::DatabaseField>::TYPE,
).set_null(false).unique(),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("p256dh"),
<String as ::cot::db::DatabaseField>::TYPE,
).set_null(false),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("auth"),
<String as ::cot::db::DatabaseField>::TYPE,
).set_null(false),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("language"),
<String as ::cot::db::DatabaseField>::TYPE,
).set_null(false),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("status"),
<String as ::cot::db::DatabaseField>::TYPE,
).set_null(false),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("created_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
).set_null(false),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("updated_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
).set_null(false),
])
.build(),
];
}
-90
View File
@@ -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(),
];
}
-24
View File
@@ -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()];
}
-56
View File
@@ -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()];
}
-35
View File
@@ -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(),
];
}
+21 -1
View File
@@ -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,
@@ -215,3 +217,21 @@ pub struct Setting {
pub value: String,
pub updated_at: chrono::NaiveDateTime,
}
/// A browser Web Push subscription belonging to a client device.
#[derive(Debug, Clone)]
#[model]
pub struct PushSubscription {
#[model(primary_key)]
pub id: Auto<i64>,
pub client_id: ForeignKey<Client>,
#[model(unique)]
pub endpoint: String,
pub p256dh: String,
pub auth: String,
pub language: String,
/// active | archived
pub status: String,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
+511 -42
View File
@@ -1,5 +1,6 @@
use chrono::Datelike;
use cot::Template;
use cot::db::{Auto, Database, Model};
use cot::db::{Auto, Database, ForeignKey, Model};
use cot::html::Html;
use cot::request::Request;
use cot::request::extractors::Path;
@@ -11,7 +12,7 @@ use tracing::info;
use cot::db::query;
use crate::i18n::{Lang, Translations};
use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit};
use crate::models::{Client, Lead, Media, PushSubscription, Setting, Testimonial, User, Visit};
use crate::telegram;
fn detect_lang(request: &Request) -> Lang {
@@ -215,6 +216,21 @@ struct PortalVisit {
media: Vec<Media>,
}
#[derive(Debug)]
struct CalendarDay {
number: u32,
class_name: &'static str,
href: Option<String>,
title: String,
}
#[derive(Debug)]
struct CalendarMonth {
label: String,
leading_blanks: Vec<u8>,
days: Vec<CalendarDay>,
}
#[derive(Debug, Template)]
#[template(path = "client_portal.html")]
struct ClientPortalTemplate<'a> {
@@ -225,6 +241,62 @@ struct ClientPortalTemplate<'a> {
past: Vec<PortalVisit>,
feedback_sent: bool,
turnstile_site_key: String,
notifications_enabled: bool,
vapid_public_key: String,
calendar_months: Vec<CalendarMonth>,
page: usize,
total_pages: usize,
has_previous_page: bool,
has_next_page: bool,
}
const PORTAL_VISITS_PER_PAGE: usize = 10;
fn query_page(request: &Request) -> usize {
request
.uri()
.query()
.and_then(|query| {
query.split('&').find_map(|part| {
part.strip_prefix("page=")
.and_then(|value| value.parse::<usize>().ok())
.filter(|page| *page > 0)
})
})
.unwrap_or(1)
}
fn month_label(year: i32, month: u32, lang: Lang) -> String {
const RU: [&str; 12] = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
const EN: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
let names = if lang == Lang::Ru { RU } else { EN };
format!("{} {year}", names[(month - 1) as usize])
}
async fn client_portal(
@@ -238,9 +310,11 @@ async fn client_portal(
.query()
.map(|q| q.split('&').any(|p| p == "feedback=ok"))
.unwrap_or(false);
let requested_page = query_page(&request);
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 +323,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 +349,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)
@@ -285,16 +364,110 @@ async fn client_portal(
}
};
let mut upcoming = Vec::new();
let mut past = Vec::new();
let mut upcoming_visits = Vec::new();
let mut past_visits = Vec::new();
for v in visits {
if v.visit_date >= today && v.status == "scheduled" {
upcoming.push(build_portal_visit(v));
upcoming_visits.push(v);
} else {
past.push(build_portal_visit(v));
past_visits.push(v);
}
}
past.reverse(); // newest first
past_visits.reverse(); // newest first
let total_pages = past_visits.len().div_ceil(PORTAL_VISITS_PER_PAGE).max(1);
let page = requested_page.min(total_pages);
let page_start = (page - 1) * PORTAL_VISITS_PER_PAGE;
let page_end = (page_start + PORTAL_VISITS_PER_PAGE).min(past_visits.len());
let past = past_visits[page_start..page_end]
.iter()
.cloned()
.map(&build_portal_visit)
.collect();
let upcoming: Vec<_> = upcoming_visits
.iter()
.cloned()
.map(&build_portal_visit)
.collect();
let mut month_keys: Vec<(i32, u32)> = past_visits
.iter()
.chain(upcoming_visits.iter())
.map(|visit| (visit.visit_date.year(), visit.visit_date.month()))
.collect();
month_keys.sort();
month_keys.dedup();
month_keys.reverse();
let calendar_months = month_keys
.into_iter()
.map(|(year, month)| {
let first = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
let next_month = if month == 12 {
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
} else {
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
};
let days_in_month = (next_month - first).num_days() as u32;
let leading_blanks = vec![0; first.weekday().num_days_from_monday() as usize];
let days = (1..=days_in_month)
.map(|day| {
let date = chrono::NaiveDate::from_ymd_opt(year, month, day).unwrap();
if let Some(_visit) = upcoming_visits
.iter()
.find(|visit| visit.visit_date == date)
{
CalendarDay {
number: day,
class_name: "future",
href: None,
title: lang.t().portal_future_visit.to_string(),
}
} else if let Some((index, visit)) = past_visits
.iter()
.enumerate()
.find(|(_, visit)| visit.visit_date == date)
{
CalendarDay {
number: day,
class_name: "past",
href: Some(format!(
"?page={}#visit-{}",
index / PORTAL_VISITS_PER_PAGE + 1,
visit.id.unwrap()
)),
title: lang.t().visit_status(&visit.status).to_string(),
}
} else {
CalendarDay {
number: day,
class_name: "empty",
href: None,
title: String::new(),
}
}
})
.collect();
CalendarMonth {
label: month_label(year, month, lang),
leading_blanks,
days,
}
})
.collect();
let notification_key = "client_notifications_enabled".to_string();
let vapid_public_key = crate::web_push::load_config(&db)
.await
.map(|config| config.public_key)
.unwrap_or_default();
// The administrator setting controls whether the client can see notification
// controls. Keep this independent from VAPID validation so a configuration
// error is visible in the modal instead of silently removing the button.
let notifications_enabled = query!(Setting, $key == notification_key)
.get(&db)
.await?
.map(|setting| setting.value == "true")
.unwrap_or(false);
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
let body = ClientPortalTemplate {
@@ -305,11 +478,203 @@ async fn client_portal(
past,
feedback_sent,
turnstile_site_key,
notifications_enabled,
vapid_public_key,
calendar_months,
page,
total_pages,
has_previous_page: page > 1,
has_next_page: page < total_pages,
}
.render()?;
html_response(body, lang)
}
#[derive(Deserialize)]
struct PushKeysForm {
p256dh: String,
auth: String,
}
#[derive(Deserialize)]
struct PushSubscriptionForm {
endpoint: String,
keys: PushKeysForm,
#[serde(default)]
language: String,
}
async fn portal_push_subscribe(
request: Request,
db: Database,
Path(token): Path<String>,
) -> cot::Result<Response> {
tracing::info!("client Web Push subscription request");
if crate::web_push::load_config(&db).await.is_none() {
return Html::new("404").into_response();
}
let client = match query!(Client, $media_token == token).get(&db).await? {
Some(client) if client.status != "deleted" => client,
_ => return Html::new("404").into_response(),
};
let bytes = request.into_body().into_bytes().await?;
let form: PushSubscriptionForm =
serde_json::from_slice(&bytes).map_err(|error| cot::Error::internal(error.to_string()))?;
if !form.endpoint.starts_with("https://")
|| form.endpoint.len() > 4096
|| form.keys.p256dh.len() > 512
|| form.keys.auth.len() > 256
{
let mut response = Response::new(cot::Body::fixed(
"{\"ok\":false,\"error\":\"invalid subscription\"}",
));
*response.status_mut() = cot::StatusCode::BAD_REQUEST;
response
.headers_mut()
.insert("content-type", "application/json".parse().unwrap());
return Ok(response);
}
tracing::info!(
client_id = client.id.unwrap(),
"client Web Push subscription saved"
);
let endpoint = form.endpoint.clone();
let now = chrono::Utc::now().naive_utc();
if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint)
.get(&db)
.await?
{
subscription.client_id = ForeignKey::PrimaryKey(Auto::fixed(client.id.unwrap()));
subscription.p256dh = form.keys.p256dh;
subscription.auth = form.keys.auth;
subscription.language = if form.language == "ru" { "ru" } else { "en" }.to_string();
subscription.status = "active".to_string();
subscription.updated_at = now;
subscription.save(&db).await?;
} else {
let mut subscription = PushSubscription {
id: Auto::auto(),
client_id: ForeignKey::PrimaryKey(Auto::fixed(client.id.unwrap())),
endpoint: form.endpoint,
p256dh: form.keys.p256dh,
auth: form.keys.auth,
language: if form.language == "ru" { "ru" } else { "en" }.to_string(),
status: "active".to_string(),
created_at: now,
updated_at: now,
};
subscription.save(&db).await?;
}
let mut response = Response::new(cot::Body::fixed("{\"ok\":true}"));
response
.headers_mut()
.insert("content-type", "application/json".parse().unwrap());
Ok(response)
}
async fn portal_push_unsubscribe(
request: Request,
db: Database,
Path(token): Path<String>,
) -> cot::Result<Response> {
let client = match query!(Client, $media_token == token).get(&db).await? {
Some(client) if client.status != "deleted" => client,
_ => return Html::new("404").into_response(),
};
let bytes = request.into_body().into_bytes().await?;
let value: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|error| cot::Error::internal(error.to_string()))?;
let endpoint = value
.get("endpoint")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string();
if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint)
.get(&db)
.await?
{
if subscription.client_id.primary_key().unwrap() == client.id.unwrap() {
subscription.status = "archived".to_string();
subscription.updated_at = chrono::Utc::now().naive_utc();
subscription.save(&db).await?;
}
}
let mut response = Response::new(cot::Body::fixed("{\"ok\":true}"));
response
.headers_mut()
.insert("content-type", "application/json".parse().unwrap());
Ok(response)
}
async fn web_manifest(_request: Request, Path(token): Path<String>) -> cot::Result<Response> {
let manifest = serde_json::json!({
"id": format!("/client/{token}"),
"name": "Pet Sitting Visits",
"short_name": "Pet Visits",
"start_url": format!("/client/{token}"),
"display": "standalone",
"background_color": "#f8f7ff",
"theme_color": "#7c6cff",
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
});
let mut response = Response::new(cot::Body::fixed(manifest.to_string()));
response
.headers_mut()
.insert("content-type", "application/manifest+json".parse().unwrap());
Ok(response)
}
async fn service_worker(_request: Request) -> cot::Result<Response> {
let script = r#"
self.addEventListener('install', function(event) {
self.skipWaiting();
});
self.addEventListener('activate', function(event) {
event.waitUntil(self.clients.claim());
});
self.addEventListener('push', function(event) {
var data = event.data ? event.data.json() : {};
event.waitUntil(self.registration.showNotification(data.title || 'Pet Visits', {
body: data.body || '', tag: data.tag || 'visit', data: { url: data.url || '/' },
icon: '/favicon.svg', badge: '/favicon.svg'
}));
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
var target = new URL(event.notification.data.url || '/', self.location.origin).href;
event.waitUntil((async function() {
var list = await clients.matchAll({ type: 'window', includeUncontrolled: true });
for (var i = 0; i < list.length; i++) {
if (list[i].url === target && 'focus' in list[i]) {
return list[i].focus();
}
}
for (var j = 0; j < list.length; j++) {
if ('navigate' in list[j] && 'focus' in list[j]) {
try {
var navigated = await list[j].navigate(target);
return navigated ? navigated.focus() : list[j].focus();
} catch (_) {}
}
}
if (clients.openWindow) return clients.openWindow(target);
})());
});
"#;
let mut response = Response::new(cot::Body::fixed(script));
response
.headers_mut()
.insert("content-type", "application/javascript".parse().unwrap());
response
.headers_mut()
.insert("service-worker-allowed", "/".parse().unwrap());
response.headers_mut().insert(
"cache-control",
"no-cache, no-store, must-revalidate".parse().unwrap(),
);
Ok(response)
}
#[derive(Deserialize)]
struct FeedbackForm {
feedback: String,
@@ -327,7 +692,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 +703,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();
@@ -363,13 +729,14 @@ async fn submit_feedback(
/// Serve media files for the client portal (no auth required, but only via token).
async fn portal_media(
_request: Request,
request: Request,
db: Database,
Path((token, media_id)): Path<(String, i64)>,
) -> 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,28 +745,97 @@ 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 {
Ok(data) => {
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"heic" | "heif" => "image/heic",
"webp" => "image/webp",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"webm" => "video/webm",
_ => "application/octet-stream",
};
let body = cot::Body::fixed(data);
let mut resp = Response::new(body);
resp.headers_mut()
.insert("content-type", content_type.parse().unwrap());
Ok(resp)
let range = request
.headers()
.get("range")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
match {
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"heic" | "heif" => "image/heic",
"webp" => "image/webp",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"webm" => "video/webm",
_ => "application/octet-stream",
};
crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await
} {
Ok(response) => Ok(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()
}
}
}
async fn portal_media_thumbnail(
_request: Request,
db: Database,
Path((token, media_id)): Path<(String, i64)>,
) -> cot::Result<Response> {
let client = match query!(Client, $media_token == token).get(&db).await? {
Some(client) if client.status != "deleted" => client,
_ => return Html::new("404").into_response(),
};
let media = match query!(Media, $id == media_id).get(&db).await? {
Some(media)
if media.client_id.primary_key().unwrap() == client.id.unwrap()
&& media.status == "active"
&& media.file_type == "photo" =>
{
media
}
_ => return Html::new("404").into_response(),
};
if let Some(visit) = &media.visit_id {
let visit_id = visit.primary_key().unwrap();
match query!(Visit, $id == visit_id).get(&db).await? {
Some(visit) if visit.status != "deleted" => {}
_ => return Html::new("404").into_response(),
}
}
match crate::uploads::ensure_thumbnail(&media.file_path).await {
Ok(path) => {
let mut response = crate::uploads::ranged_file_response(&path, "image/jpeg", None)
.await
.map_err(|error| cot::Error::internal(error.to_string()))?;
response.headers_mut().insert(
"cache-control",
"private, max-age=31536000, immutable".parse().unwrap(),
);
Ok(response)
}
Err(error) => {
tracing::warn!(media_id, %error, "failed to create portal media thumbnail");
crate::uploads::ranged_file_response(
&media.file_path,
crate::uploads::content_type_for_path(&media.file_path),
None,
)
.await
.map_err(|error| cot::Error::internal(error.to_string()))
}
Err(_) => Html::new("404").into_response(),
}
}
@@ -416,7 +852,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 +869,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 +976,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)
}
@@ -539,6 +987,12 @@ pub fn public_router() -> Router {
Router::with_urls([
Route::with_handler_and_name("/", landing_page, "landing"),
Route::with_handler_and_name("/favicon.svg", favicon, "favicon"),
Route::with_handler_and_name(
"/client/{token}/manifest.webmanifest",
web_manifest,
"web-manifest",
),
Route::with_handler_and_name("/service-worker.js", service_worker, "service-worker"),
Route::with_handler_and_name("/static/{filename}", serve_static, "static-file"),
Route::with_handler_and_name("/robots.txt", robots_txt, "robots-txt"),
Route::with_handler_and_name("/sitemap.xml", sitemap_xml, "sitemap-xml"),
@@ -549,6 +1003,16 @@ pub fn public_router() -> Router {
"testimonial-image",
),
Route::with_handler_and_name("/client/{token}", client_portal, "client-portal"),
Route::with_handler_and_name(
"/client/{token}/push/subscribe",
portal_push_subscribe,
"client-push-subscribe",
),
Route::with_handler_and_name(
"/client/{token}/push/unsubscribe",
portal_push_unsubscribe,
"client-push-unsubscribe",
),
Route::with_handler_and_name(
"/client/{token}/{visit_id}/feedback",
submit_feedback,
@@ -559,5 +1023,10 @@ pub fn public_router() -> Router {
portal_media,
"client-media",
),
Route::with_handler_and_name(
"/client/{token}/media/{media_id}/thumbnail",
portal_media_thumbnail,
"client-media-thumbnail",
),
])
}
+247
View File
@@ -0,0 +1,247 @@
use std::path::{Path, PathBuf};
use cot::response::Response;
use cot::{Body, StatusCode};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
const THUMBNAIL_MAX_DIMENSION: u32 = 360;
const THUMBNAIL_JPEG_QUALITY: u8 = 70;
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 fn thumbnail_db_path(db_path: &str) -> String {
match db_path.rsplit_once('.') {
Some((stem, _)) => format!("{stem}.thumb.jpg"),
None => format!("{db_path}.thumb.jpg"),
}
}
pub fn content_type_for_path(path: &str) -> &'static str {
match path.rsplit('.').next().unwrap_or("") {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"heic" | "heif" => "image/heic",
"webp" => "image/webp",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"webm" => "video/webm",
_ => "application/octet-stream",
}
}
pub async fn ensure_thumbnail(db_path: &str) -> std::io::Result<String> {
let thumbnail_path = thumbnail_db_path(db_path);
if tokio::fs::try_exists(resolve_db_path(&thumbnail_path)).await? {
return Ok(thumbnail_path);
}
let data = read_db_file(db_path).await?;
let image = image::load_from_memory(&data)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
let thumbnail = image.thumbnail(THUMBNAIL_MAX_DIMENSION, THUMBNAIL_MAX_DIMENSION);
let rgb = thumbnail.to_rgb8();
let mut encoded = Vec::new();
let mut encoder =
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, THUMBNAIL_JPEG_QUALITY);
encoder.encode_image(&rgb).map_err(std::io::Error::other)?;
write_db_file(&thumbnail_path, &encoded).await?;
Ok(thumbnail_path)
}
enum ByteRange {
Full,
Partial { start: u64, end: u64 },
Unsatisfiable,
}
fn parse_byte_range(header: Option<&str>, file_len: u64) -> ByteRange {
let Some(value) = header else {
return ByteRange::Full;
};
let Some(spec) = value.strip_prefix("bytes=") else {
return ByteRange::Unsatisfiable;
};
if spec.contains(',') || file_len == 0 {
return ByteRange::Unsatisfiable;
}
let Some((start, end)) = spec.split_once('-') else {
return ByteRange::Unsatisfiable;
};
if start.is_empty() {
let Ok(suffix_len) = end.parse::<u64>() else {
return ByteRange::Unsatisfiable;
};
if suffix_len == 0 {
return ByteRange::Unsatisfiable;
}
let start = file_len.saturating_sub(suffix_len);
return ByteRange::Partial {
start,
end: file_len - 1,
};
}
let Ok(start) = start.parse::<u64>() else {
return ByteRange::Unsatisfiable;
};
if start >= file_len {
return ByteRange::Unsatisfiable;
}
let end = if end.is_empty() {
file_len - 1
} else {
let Ok(end) = end.parse::<u64>() else {
return ByteRange::Unsatisfiable;
};
end.min(file_len - 1)
};
if end < start {
return ByteRange::Unsatisfiable;
}
ByteRange::Partial { start, end }
}
/// Read a file into an HTTP response, honoring a single `Range: bytes=...` request.
pub async fn ranged_file_response(
db_path: &str,
content_type: &str,
range_header: Option<&str>,
) -> std::io::Result<Response> {
let path = resolve_db_path(db_path);
let mut file = tokio::fs::File::open(path).await?;
let file_len = file.metadata().await?.len();
let range = parse_byte_range(range_header, file_len);
let (status, body, content_range) = match range {
ByteRange::Full => {
let mut data = Vec::with_capacity(file_len as usize);
file.read_to_end(&mut data).await?;
(StatusCode::OK, data, None)
}
ByteRange::Partial { start, end } => {
let range_len = end - start + 1;
let mut data = vec![0; range_len as usize];
file.seek(std::io::SeekFrom::Start(start)).await?;
file.read_exact(&mut data).await?;
(
StatusCode::PARTIAL_CONTENT,
data,
Some(format!("bytes {start}-{end}/{file_len}")),
)
}
ByteRange::Unsatisfiable => {
let mut response = Response::new(Body::fixed(Vec::<u8>::new()));
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
response
.headers_mut()
.insert("accept-ranges", "bytes".parse().unwrap());
response.headers_mut().insert(
"content-range",
format!("bytes */{file_len}").parse().unwrap(),
);
return Ok(response);
}
};
let content_len = body.len();
let mut response = Response::new(Body::fixed(body));
*response.status_mut() = status;
response
.headers_mut()
.insert("content-type", content_type.parse().unwrap());
response
.headers_mut()
.insert("accept-ranges", "bytes".parse().unwrap());
response
.headers_mut()
.insert("content-length", content_len.to_string().parse().unwrap());
if let Some(content_range) = content_range {
response
.headers_mut()
.insert("content-range", content_range.parse().unwrap());
}
Ok(response)
}
pub async fn remove_db_file(db_path: &str) -> std::io::Result<()> {
tokio::fs::remove_file(resolve_db_path(db_path)).await
}
#[cfg(test)]
mod tests {
use super::{ByteRange, parse_byte_range};
#[test]
fn parses_byte_ranges() {
assert!(matches!(
parse_byte_range(Some("bytes=10-19"), 100),
ByteRange::Partial { start: 10, end: 19 }
));
assert!(matches!(
parse_byte_range(Some("bytes=90-"), 100),
ByteRange::Partial { start: 90, end: 99 }
));
assert!(matches!(
parse_byte_range(Some("bytes=-10"), 100),
ByteRange::Partial { start: 90, end: 99 }
));
assert!(matches!(
parse_byte_range(Some("bytes=100-"), 100),
ByteRange::Unsatisfiable
));
}
}
+190
View File
@@ -0,0 +1,190 @@
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use cot::db::{Database, Model, query};
use serde_json::json;
use web_push_native::jwt_simple::algorithms::ES256KeyPair;
use web_push_native::p256::PublicKey;
use web_push_native::{Auth, WebPushBuilder};
use crate::models::{Client, PushSubscription, Setting, Visit};
pub struct VapidConfig {
pub public_key: String,
private_key: String,
subject: String,
}
fn normalize_key_pair(public_key: &str, private_key: &str) -> Option<(String, String)> {
let strip_assignment = |value: &str, name: &str| {
value
.trim()
.strip_prefix(&format!("{name}="))
.unwrap_or(value.trim())
.trim()
.to_string()
};
let public_key = strip_assignment(public_key, "WEB_PETTING_VAPID_PUBLIC_KEY");
let private_key = strip_assignment(private_key, "WEB_PETTING_VAPID_PRIVATE_KEY");
let public_bytes = URL_SAFE_NO_PAD.decode(&public_key).ok()?;
let private_bytes = URL_SAFE_NO_PAD.decode(&private_key).ok()?;
if public_bytes.len() == 65 && public_bytes.first() == Some(&4) && private_bytes.len() == 32 {
return Some((public_key, private_key));
}
None
}
pub async fn load_config(db: &Database) -> Option<VapidConfig> {
let settings = Setting::objects().all(db).await.ok()?;
let value = |key: &str| {
settings
.iter()
.find(|setting| setting.key == key)
.map(|setting| setting.value.trim().to_string())
.filter(|value| !value.is_empty())
};
let raw_public_key = value("vapid_public_key")?;
let raw_private_key = value("vapid_private_key")?;
let (public_key, private_key) = match normalize_key_pair(&raw_public_key, &raw_private_key) {
Some(keys) => keys,
None => {
tracing::warn!("invalid VAPID configuration in database");
return None;
}
};
let subject_value =
value("vapid_subject").unwrap_or_else(|| "mailto:admin@localhost".to_string());
let subject = subject_value
.strip_prefix("WEB_PETTING_VAPID_SUBJECT=")
.unwrap_or(&subject_value)
.trim()
.to_string();
Some(VapidConfig {
public_key,
private_key,
subject,
})
}
pub async fn initialize(db: &Database) {
if load_config(db).await.is_some() {
tracing::info!("VAPID configuration loaded from database");
} else {
tracing::info!("VAPID configuration is not set; client Web Push is disabled");
}
}
pub async fn notify_visit_completed(db: &Database, visit: &Visit) {
let setting_key = "client_notifications_enabled".to_string();
let enabled = query!(Setting, $key == setting_key)
.get(db)
.await
.ok()
.flatten()
.map(|setting| setting.value == "true")
.unwrap_or(false);
let Some(config) = load_config(db).await else {
return;
};
if !enabled {
return;
}
let client_id = visit.client_id.primary_key().unwrap();
let client = match query!(Client, $id == client_id).get(db).await {
Ok(Some(client)) => client,
_ => return,
};
let active = "active".to_string();
let subscriptions = match query!(PushSubscription, $status == active).all(db).await {
Ok(items) => items
.into_iter()
.filter(|item| item.client_id.primary_key().unwrap() == client_id)
.collect::<Vec<_>>(),
Err(error) => {
tracing::warn!(%error, "failed to load Web Push subscriptions");
return;
}
};
let mut visible_visits = match Visit::objects().all(db).await {
Ok(visits) => visits
.into_iter()
.filter(|item| {
item.client_id.primary_key().unwrap() == client_id
&& item.status != "cancelled"
&& item.status != "deleted"
})
.collect::<Vec<_>>(),
Err(_) => Vec::new(),
};
visible_visits.sort_by(|a, b| {
b.visit_date
.cmp(&a.visit_date)
.then(b.time_start.cmp(&a.time_start))
});
let page = visible_visits
.iter()
.position(|item| item.id.unwrap() == visit.id.unwrap())
.map(|index| index / 10 + 1)
.unwrap_or(1);
for mut subscription in subscriptions {
let is_ru = subscription.language == "ru";
let date = visit.visit_date.format("%d.%m.%Y");
let body = if is_ru {
format!("Визит {date} завершён. Нажмите для просмотра медиа и комментариев.")
} else {
format!("Visit {date} is complete. Click to view media and comments.")
};
let payload = json!({
"title": if is_ru { "Визит завершён" } else { "Visit completed" },
"body": body,
"url": format!("/client/{}?page={}#visit-{}", client.media_token, page, visit.id.unwrap()),
"tag": format!("visit-{}", visit.id.unwrap()),
});
match send(&subscription, payload.to_string().into_bytes(), &config).await {
Ok(status)
if status == reqwest::StatusCode::NOT_FOUND
|| status == reqwest::StatusCode::GONE =>
{
subscription.status = "archived".to_string();
subscription.updated_at = chrono::Utc::now().naive_utc();
if let Err(error) = subscription.save(db).await {
tracing::warn!(%error, "failed to archive expired Web Push subscription");
}
}
Ok(status) if status.is_success() => {}
Ok(status) => tracing::warn!(%status, "Web Push gateway rejected notification"),
Err(error) => tracing::warn!(%error, "failed to send Web Push notification"),
}
}
}
async fn send(
subscription: &PushSubscription,
content: Vec<u8>,
config: &VapidConfig,
) -> Result<reqwest::StatusCode, Box<dyn std::error::Error + Send + Sync>> {
let private = URL_SAFE_NO_PAD.decode(&config.private_key)?;
let key_pair = ES256KeyPair::from_bytes(&private)?;
let p256dh = URL_SAFE_NO_PAD.decode(&subscription.p256dh)?;
let auth = URL_SAFE_NO_PAD.decode(&subscription.auth)?;
let builder = WebPushBuilder::new(
subscription.endpoint.parse()?,
PublicKey::from_sec1_bytes(&p256dh)?,
Auth::clone_from_slice(&auth),
)
.with_vapid(&key_pair, &config.subject);
let request = builder.build(content)?;
let (parts, body) = request.into_parts();
let response = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?
.request(parts.method, parts.uri.to_string())
.headers(parts.headers)
.body(body)
.send()
.await?;
Ok(response.status())
}
+6 -1
View File
@@ -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 %}
+47 -10
View File
@@ -27,12 +27,15 @@
{% 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() }}/thumbnail" alt="" loading="lazy">
</a>
{% else %}
<a href="/admin/uploads/{{ item.media.id }}" data-lightbox="video">
<div class="video-thumb">🎬</div>
<a href="/admin/uploads/{{ item.media.id.unwrap() }}" data-lightbox="video">
<div class="video-thumb">
<video src="/admin/uploads/{{ item.media.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
<span class="video-play"></span>
</div>
</a>
{% endif %}
<div class="media-info">
@@ -45,13 +48,26 @@
{% 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>
</div>
{% endfor %}
</div>
{% if total_pages > 1 %}
<nav class="media-pagination" aria-label="Pagination">
{% if page > 1 %}
<a class="button is-small" href="/admin/media?lang={{ lang.code() }}&page={{ page - 1 }}{% if filter_client_id > 0 %}&client_id={{ filter_client_id }}{% endif %}"></a>
{% endif %}
{% for p in 1..=total_pages %}
<a class="button is-small{% if p == page %} is-link{% endif %}" href="/admin/media?lang={{ lang.code() }}&page={{ p }}{% if filter_client_id > 0 %}&client_id={{ filter_client_id }}{% endif %}">{{ p }}</a>
{% endfor %}
{% if page < total_pages %}
<a class="button is-small" href="/admin/media?lang={{ lang.code() }}&page={{ page + 1 }}{% if filter_client_id > 0 %}&client_id={{ filter_client_id }}{% endif %}"></a>
{% endif %}
</nav>
{% endif %}
{% endif %}
<style>
@@ -73,17 +89,38 @@
display: block;
}
.media-card .video-thumb {
position: relative;
width: 100%;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
background: #f0f0f0;
background: #111;
}
.media-card .video-thumb video {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.media-card .video-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 2.5rem;
line-height: 1;
text-shadow: 0 1px 5px #000;
pointer-events: none;
}
.media-info {
padding: 0.6rem 0.75rem;
}
.media-pagination {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.35rem;
margin-top: 1.25rem;
}
.media-meta {
display: flex;
justify-content: space-between;
+41 -18
View File
@@ -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,23 @@
{% 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() }}/thumbnail" alt="" loading="lazy">
</a>
{% else %}
<a href="/admin/uploads/{{ m.id }}" data-lightbox="video">
<div class="video-thumb-sm">🎬</div>
<a href="/admin/uploads/{{ m.id.unwrap() }}" data-lightbox="video">
<div class="video-thumb-sm">
<video src="/admin/uploads/{{ m.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
<span class="video-play"></span>
</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 +139,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 }}');">
@@ -238,13 +239,27 @@
display: block;
}
.visit-media-item .video-thumb-sm {
position: relative;
width: 100%;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
background: #f0f0f0;
background: #111;
}
.visit-media-item .video-thumb-sm video {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.visit-media-item .video-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 1.6rem;
line-height: 1;
text-shadow: 0 1px 4px #000;
pointer-events: none;
}
.visit-media-item .media-cap {
font-size: 0.7rem;
@@ -254,6 +269,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;
+59
View File
@@ -59,6 +59,65 @@
</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="checkbox">
<input type="checkbox" name="client_notifications_enabled" value="true"{% if client_notifications_checked %} checked{% endif %}>
{{ t.settings_client_notifications_enabled }}
</label>
<p class="help">{{ t.settings_client_notifications_help }}</p>
</div>
<blockquote class="notification is-warning is-light" style="padding:0.8rem 1rem;font-size:0.85rem;margin:0.75rem 0;">
<p>{{ t.settings_vapid_warning }}</p>
<p style="margin-top:0.45rem;">{{ t.settings_vapid_generate }}</p>
<code style="display:inline-block;margin-top:0.2rem;user-select:all;">cargo run --bin generate_vapid</code>
</blockquote>
<div class="field">
<label class="label">{{ t.settings_vapid_public_key }}</label>
<div class="control">
<input class="input" type="text" name="vapid_public_key" autocomplete="off" value="{% for s in &settings %}{% if s.key == "vapid_public_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_vapid_private_key }}</label>
<div class="control">
<input class="input" type="password" name="vapid_private_key" autocomplete="new-password" value="{% for s in &settings %}{% if s.key == "vapid_private_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_vapid_subject }}</label>
<div class="control">
<input class="input" type="text" name="vapid_subject" placeholder="mailto:admin@example.com" value="{% for s in &settings %}{% if s.key == "vapid_subject" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<details style="margin:0.9rem 0 1.25rem;border:1px solid #e8e5f5;border-radius:8px;background:#faf9ff;">
<summary style="cursor:pointer;padding:0.7rem 0.85rem;font-weight:600;font-size:0.9rem;">
{{ t.settings_push_subscribers }} ({{ push_subscribers.len() }})
</summary>
<div style="padding:0 0.85rem 0.85rem;overflow-x:auto;">
{% if push_subscribers.is_empty() %}
<p class="help">{{ t.settings_push_no_subscribers }}</p>
{% else %}
<table class="table is-fullwidth is-striped is-narrow" style="font-size:0.8rem;background:transparent;">
<thead><tr>
<th>{{ t.settings_push_client }}</th>
<th>{{ t.settings_push_devices }}</th>
<th>{{ t.settings_push_language }}</th>
<th>{{ t.settings_push_updated }}</th>
</tr></thead>
<tbody>
{% for subscriber in &push_subscribers %}
<tr>
<td>{{ subscriber.client_name }}</td>
<td>{{ subscriber.device_count }}</td>
<td>{{ subscriber.languages }}</td>
<td style="white-space:nowrap;">{{ subscriber.last_updated }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</details>
<div class="field">
<label class="label">{{ t.settings_telegram_bot_token }}</label>
<div class="control">
+219 -9
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t.portal_title }} — {{ client.name }}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="manifest" href="/client/{{ client.media_token }}/manifest.webmanifest">
<meta name="theme-color" content="#7c6cff">
<meta name="apple-mobile-web-app-capable" content="yes">
{% if !turnstile_site_key.is_empty() %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
@@ -17,12 +20,15 @@
padding: 0 0 2rem;
}
.portal-header {
position: relative;
background: linear-gradient(135deg, #7c6cff, #b06cff);
color: #fff; padding: 2rem 1.5rem 1.5rem; text-align: center;
}
.portal-header h1 { font-size: 1.5rem; font-weight: 700; }
.portal-header .sub { opacity: 0.85; font-size: 0.9rem; margin-top: 0.25rem; }
.container { max-width: 700px; margin: 0 auto; padding: 0 1rem; }
.container { max-width: 1100px; margin: 0 auto; padding: 0 1rem; }
.portal-grid { display: grid; grid-template-columns: minmax(0, 700px) 320px; gap: 1.25rem; align-items: start; }
.portal-settings { position: absolute; right: 1rem; bottom: 1rem; width: 38px; height: 38px; border: 0; border-radius: 50%; background: rgba(255,255,255,.2); color: #fff; font-size: 1.1rem; cursor: pointer; }
.section-title {
font-size: 1.15rem; font-weight: 700; margin: 1.5rem 0 0.75rem;
padding-bottom: 0.4rem; border-bottom: 2px solid #ede7f6;
@@ -52,8 +58,16 @@
width: 80px; height: 60px; object-fit: cover; border-radius: 6px;
}
.media-row .vid-thumb {
width: 80px; height: 60px; border-radius: 6px; background: #f0f0f0;
display: flex; align-items: center; justify-content: center; font-size: 1.5rem;
position: relative; width: 80px; height: 60px; border-radius: 6px;
overflow: hidden; background: #111;
}
.media-row .vid-thumb video {
width: 100%; height: 100%; display: block; object-fit: cover;
}
.media-row .video-play {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
color: white; font-size: 1.35rem; line-height: 1;
text-shadow: 0 1px 4px #000; pointer-events: none;
}
.feedback-form { margin-top: 0.6rem; }
.feedback-form textarea {
@@ -102,6 +116,33 @@
font-weight: 700; min-width: 5.5rem;
}
.upcoming-row .up-time { color: #7a7599; }
.calendar-panel { position: sticky; top: 1rem; margin-top: 1.5rem; background: #fff; border: 1px solid #eee; border-radius: 12px; padding: .85rem; }
.calendar-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .65rem; }
.calendar-head button { border: 0; background: #f0edff; color: #6255c7; width: 30px; height: 30px; border-radius: 50%; cursor: pointer; }
.calendar-title { font-size: .95rem; font-weight: 700; }
.calendar-weekdays, .calendar-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; text-align: center; }
.calendar-weekdays { color: #999; font-size: .68rem; margin-bottom: 3px; }
.calendar-day { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; border-radius: 7px; font-size: .78rem; color: #aaa; }
.calendar-day.past { background: #ede9ff; color: #5145a6; font-weight: 700; text-decoration: none; }
.calendar-day.past:hover { background: #dcd5ff; }
.calendar-day.future { background: #f3f3f3; color: #bbb; border: 1px dashed #ddd; }
.calendar-month[hidden] { display: none; }
.calendar-legend { margin-top: .65rem; font-size: .72rem; color: #999; }
.pagination { display: flex; justify-content: center; align-items: center; gap: .75rem; margin: 1rem 0; font-size: .85rem; }
.pagination a { color: #6558c8; text-decoration: none; padding: .35rem .7rem; background: #fff; border: 1px solid #e5e1ff; border-radius: 8px; }
.modal-bg { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(20,18,40,.55); align-items: center; justify-content: center; padding: 1rem; }
.modal-bg.open { display: flex; }
.notification-modal { width: min(420px, 100%); background: #fff; border-radius: 14px; padding: 1.2rem; box-shadow: 0 15px 50px rgba(0,0,0,.25); }
.notification-modal h2 { font-size: 1.15rem; margin-bottom: .45rem; }
.notification-modal p { font-size: .88rem; color: #777; }
.notification-actions { display: flex; gap: .5rem; margin-top: 1rem; }
.notification-actions button { border: 0; border-radius: 8px; padding: .55rem .9rem; cursor: pointer; }
.notification-primary { background: #7567e8; color: #fff; }
@media (max-width: 800px) {
.portal-grid { display: flex; flex-direction: column; }
.calendar-panel { position: static; order: -1; width: 100%; margin-top: 1rem; }
.visits-column { width: 100%; }
}
</style>
</head>
<body>
@@ -113,6 +154,7 @@
<div class="portal-header">
<h1>{{ t.portal_title }}</h1>
<div class="sub">{{ client.name }}</div>
{% if notifications_enabled %}<button class="portal-settings" type="button" onclick="openNotificationSettings()" aria-label="{{ t.portal_notifications }}"></button>{% endif %}
</div>
<div class="container">
@@ -121,13 +163,15 @@
<div class="success-msg">{{ t.portal_feedback_thanks }}</div>
{% endif %}
<div class="portal-grid">
<main class="visits-column">
<!-- Past visits with media first -->
<h2 class="section-title">{{ t.portal_past }}</h2>
{% if past.is_empty() %}
<p class="empty-msg">{{ t.portal_no_past }}</p>
{% else %}
{% for pv in &past %}
<div class="visit-card">
<div class="visit-card" id="visit-{{ pv.visit.id.unwrap() }}">
<div class="visit-card-head">
<span class="date">{{ pv.visit.visit_date }}</span>
<span class="badge-sm badge-{{ pv.visit.status }}">
@@ -147,12 +191,15 @@
<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() }}/thumbnail" alt="" loading="lazy">
</a>
{% else %}
<a href="/client/{{ client.media_token }}/media/{{ m.id }}" data-lightbox="video">
<div class="vid-thumb">🎬</div>
<a href="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" data-lightbox="video">
<div class="vid-thumb">
<video src="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
<span class="video-play"></span>
</div>
</a>
{% endif %}
{% endfor %}
@@ -189,6 +236,13 @@
{% endif %}
</div>
{% endfor %}
{% if total_pages > 1 %}
<nav class="pagination">
{% if has_previous_page %}<a href="?page={{ page - 1 }}">← {{ t.portal_previous }}</a>{% endif %}
<span>{{ page }} / {{ total_pages }}</span>
{% if has_next_page %}<a href="?page={{ page + 1 }}">{{ t.portal_next }} →</a>{% endif %}
</nav>
{% endif %}
{% endif %}
<!-- Compact upcoming schedule -->
@@ -198,14 +252,56 @@
{% for pv in &upcoming %}
<div class="upcoming-row">
<span class="up-date">{{ pv.visit.visit_date }}</span>
<span class="up-time">{{ pv.visit.time_start }} — {{ pv.visit.time_end }}</span>
<span class="up-time">{{ t.portal_future_visit }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</main>
{% if !calendar_months.is_empty() %}
<aside class="calendar-panel">
<div class="calendar-head">
<button type="button" onclick="moveCalendar(1)" aria-label="{{ t.portal_previous }}"></button>
<span class="calendar-title" id="calendarTitle">{{ t.portal_calendar }}</span>
<button type="button" onclick="moveCalendar(-1)" aria-label="{{ t.portal_next }}"></button>
</div>
<div class="calendar-weekdays"><span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span></div>
{% for month in &calendar_months %}
<div class="calendar-month" data-label="{{ month.label }}"{% if !loop.first %} hidden{% endif %}>
<div class="calendar-days">
{% for _blank in &month.leading_blanks %}<span></span>{% endfor %}
{% for day in &month.days %}
{% if let Some(href) = day.href.as_deref() %}
<a class="calendar-day {{ day.class_name }}" href="{{ href }}" title="{{ day.title }}">{{ day.number }}</a>
{% else %}
<span class="calendar-day {{ day.class_name }}" title="{{ day.title }}">{{ day.number }}</span>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
<div class="calendar-legend">{{ t.portal_future_visit }} — ···</div>
</aside>
{% endif %}
</div>
</div>
{% if notifications_enabled %}
<div class="modal-bg" id="notificationModal" onclick="if(event.target===this) closeNotificationSettings()">
<div class="notification-modal">
<h2>{{ t.portal_notifications }}</h2>
<p id="notificationText">{{ t.portal_notifications_text }}</p>
<p id="notificationStatus" style="display:none;margin-top:0.65rem;font-weight:600;"></p>
<div class="notification-actions">
<button type="button" class="notification-primary" id="notificationToggle">{{ t.portal_notifications_enable }}</button>
<button type="button" onclick="closeNotificationSettings()"></button>
</div>
</div>
</div>
{% endif %}
<script>
function showFbEdit(id) {
document.getElementById('fb-view-' + id).style.display = 'none';
@@ -215,6 +311,120 @@ function hideFbEdit(id) {
document.getElementById('fb-form-' + id).style.display = 'none';
document.getElementById('fb-view-' + id).style.display = '';
}
var calendarIndex = 0;
function renderCalendar() {
var months = document.querySelectorAll('.calendar-month');
if (!months.length) return;
months.forEach(function(month, index) { month.hidden = index !== calendarIndex; });
document.getElementById('calendarTitle').textContent = months[calendarIndex].dataset.label;
}
function moveCalendar(delta) {
var months = document.querySelectorAll('.calendar-month');
calendarIndex = Math.max(0, Math.min(months.length - 1, calendarIndex + delta));
renderCalendar();
}
renderCalendar();
{% if notifications_enabled %}
(function() {
var toggle = document.getElementById('notificationToggle');
var registration;
var subscription;
var status = document.getElementById('notificationStatus');
function decodeKey(value) {
var padding = '='.repeat((4 - value.length % 4) % 4);
var raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(raw, function(char) { return char.charCodeAt(0); });
}
function sameKey(left, right) {
if (!left || left.byteLength !== right.byteLength) return false;
var a = new Uint8Array(left), b = new Uint8Array(right);
return a.every(function(value, index) { return value === b[index]; });
}
function showStatus(message, error) {
status.textContent = message;
status.style.display = '';
status.style.color = error ? '#b42318' : '#067647';
}
async function saveSubscription(value) {
var payload = value.toJSON();
payload.language = '{{ lang.code() }}';
var response = await fetch('/client/{{ client.media_token }}/push/subscribe', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
if (!response.ok) throw new Error('Subscription API returned HTTP ' + response.status);
}
async function refresh() {
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
toggle.disabled = true;
showStatus('{{ t.portal_notifications_unsupported }}', true);
return;
}
if (!'{{ vapid_public_key }}') {
toggle.disabled = true;
showStatus('{{ t.portal_notifications_error }}', true);
return;
}
registration = await navigator.serviceWorker.register('/service-worker.js');
await registration.update();
await navigator.serviceWorker.ready;
subscription = await registration.pushManager.getSubscription();
var expectedKey = decodeKey('{{ vapid_public_key }}');
if (subscription && !sameKey(subscription.options.applicationServerKey, expectedKey)) {
await subscription.unsubscribe();
subscription = null;
}
if (subscription) {
await saveSubscription(subscription);
showStatus('{{ t.portal_notifications_active }}', false);
}
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
}
window.openNotificationSettings = function() {
document.getElementById('notificationModal').classList.add('open');
refresh().catch(function(error) {
console.error(error);
showStatus('{{ t.portal_notifications_error }}', true);
});
};
window.closeNotificationSettings = function() { document.getElementById('notificationModal').classList.remove('open'); };
toggle.addEventListener('click', async function() {
toggle.disabled = true;
try {
if (!registration) await refresh();
if (subscription) {
await fetch('/client/{{ client.media_token }}/push/unsubscribe', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({endpoint: subscription.endpoint})
});
await subscription.unsubscribe();
subscription = null;
} else {
var permission = await Notification.requestPermission();
if (permission !== 'granted') {
document.getElementById('notificationText').textContent = '{{ t.portal_notifications_denied }}';
return;
}
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: decodeKey('{{ vapid_public_key }}')
});
await saveSubscription(subscription);
showStatus('{{ t.portal_notifications_active }}', false);
}
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
} catch (error) {
console.error(error);
showStatus('{{ t.portal_notifications_error }}', true);
} finally {
toggle.disabled = false;
}
});
refresh().catch(function(error) {
console.error(error);
showStatus('{{ t.portal_notifications_error }}', true);
});
})();
{% endif %}
</script>
{% include "partials/lightbox.html" %}
</body>