Files
web-petting/src/admin.rs
T
ab f7dcefeea6
Build and Publish / Build and Publish Docker Image (push) Successful in 7m6s
Added claudflare Turnstile captcha support
2026-05-18 22:12:54 +03:00

2189 lines
68 KiB
Rust

use cot::Template;
use cot::db::{Auto, Database, ForeignKey, Model, query};
use cot::html::Html;
use cot::request::Request;
use cot::request::extractors::Path;
use cot::response::{IntoResponse, Redirect, Response};
use cot::router::{Route, Router};
use cot::session::Session;
use serde::Deserialize;
use crate::i18n::{Lang, Translations};
use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit};
use crate::telegram;
const SESSION_USER_ID: &str = "user_id";
const SESSION_USER_NAME: &str = "user_name";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn detect_lang(request: &Request) -> Lang {
if let Some(q) = request.uri().query() {
for pair in q.split('&') {
if let Some(code) = pair.strip_prefix("lang=") {
if let Some(lang) = Lang::from_code(code) {
return lang;
}
}
}
}
if let Some(cookie) = request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
{
for part in cookie.split(';') {
let part = part.trim();
if let Some(code) = part.strip_prefix("lang=") {
if let Some(lang) = Lang::from_code(code.trim()) {
return lang;
}
}
}
}
request
.headers()
.get("accept-language")
.and_then(|v| v.to_str().ok())
.map(Lang::from_accept_language)
.unwrap_or(Lang::En)
}
fn lang_cookie(lang: Lang) -> String {
format!(
"lang={}; Path=/; SameSite=Lax; Max-Age=31536000",
lang.code()
)
}
fn html_response(body: String, lang: Lang) -> cot::Result<Response> {
Html::new(body)
.with_header("set-cookie", lang_cookie(lang))
.into_response()
}
async fn parse_form_from_request<T: serde::de::DeserializeOwned>(
request: Request,
) -> cot::Result<(Lang, T)> {
let lang = detect_lang(&request);
let body = request.into_body();
let bytes = body.into_bytes().await?;
let form: T =
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?;
Ok((lang, form))
}
fn has_query_flag(request: &Request, flag: &str) -> bool {
request
.uri()
.query()
.map(|q| {
q.split('&')
.any(|p| p == format!("{}=1", flag) || p == flag)
})
.unwrap_or(false)
}
/// Soft pastel palette for client calendar colors.
const CLIENT_COLORS: &[&str] = &[
"#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", "#8e6bbf", "#5cb8a5", "#c77c4f",
"#a3729a", "#6b9e5e", "#d48b6c", "#7a8fc4", "#c45d7c", "#5eab7d", "#b8864e", "#9476b8",
"#6aafb5", "#d4785e", "#7f8e5b", "#b56c9e",
];
fn rand_client_color() -> &'static str {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let s = RandomState::new();
let mut h = s.build_hasher();
h.write_u64(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64,
);
CLIENT_COLORS[(h.finish() as usize) % CLIENT_COLORS.len()]
}
fn now_utc() -> chrono::NaiveDateTime {
chrono::Utc::now().naive_utc()
}
fn rand_token() -> String {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let s = RandomState::new();
let mut h = s.build_hasher();
h.write_u64(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64,
);
format!("{:016x}", h.finish())
}
/// Check session for logged-in user. Returns None if not authenticated.
async fn get_admin_name(session: &Session) -> Option<String> {
session
.get::<String>(SESSION_USER_NAME)
.await
.ok()
.flatten()
}
/// Get admin user ID from session.
async fn get_admin_id(session: &Session) -> Option<i64> {
session.get::<i64>(SESSION_USER_ID).await.ok().flatten()
}
/// Redirect to login if not authenticated.
async fn require_auth(session: &Session, lang: Lang) -> Result<String, Response> {
match get_admin_name(session).await {
Some(name) => Ok(name),
None => {
let resp = Redirect::new(format!("/admin/login?lang={}", lang.code()))
.into_response()
.unwrap();
Err(resp)
}
}
}
// ---------------------------------------------------------------------------
// Templates
// ---------------------------------------------------------------------------
#[derive(Debug)]
struct TodayVisit {
visit: Visit,
client_name: String,
client_phone: String,
client_address: String,
client_color: String,
}
#[derive(Debug)]
struct RecentFeedback {
visit_id: i64,
client_name: String,
visit_date: chrono::NaiveDate,
feedback: String,
}
#[derive(Debug, Template)]
#[template(path = "admin/dashboard.html")]
struct DashboardTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
today_visits: Vec<TodayVisit>,
feedbacks: Vec<RecentFeedback>,
feedback_page: usize,
feedback_total_pages: usize,
}
#[derive(Debug, Template)]
#[template(path = "admin/login.html")]
struct LoginTemplate<'a> {
t: &'a Translations,
lang: Lang,
error: Option<String>,
turnstile_site_key: String,
}
#[derive(Debug, Template)]
#[template(path = "admin/leads.html")]
struct LeadsTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
leads: Vec<Lead>,
show_all: bool,
}
#[derive(Debug, Template)]
#[template(path = "admin/clients.html")]
struct ClientsTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
clients: Vec<Client>,
show_all: bool,
}
#[derive(Debug, Template)]
#[template(path = "admin/client_form.html")]
struct ClientFormTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
title: &'a str,
action_url: &'a str,
submit_label: &'a str,
client_name: &'a str,
client_phone: &'a str,
client_email: &'a str,
client_address: &'a str,
client_notes: &'a str,
client_color: &'a str,
is_edit: bool,
client_id: i64,
client_status: &'a str,
client_token: &'a str,
}
#[derive(Debug, Template)]
#[template(path = "admin/users.html")]
struct UsersTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
users: Vec<User>,
error: Option<String>,
}
#[derive(Debug, Template)]
#[template(path = "admin/settings.html")]
struct SettingsTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
settings: Vec<Setting>,
saved: bool,
}
#[derive(Debug, Template)]
#[template(path = "admin/schedule.html")]
struct ScheduleTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
timezone: String,
}
#[derive(Debug, Template)]
#[template(path = "admin/schedule_new.html")]
struct ScheduleNewTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
clients: Vec<Client>,
users: Vec<User>,
current_user_id: i64,
timezone: String,
}
#[derive(Debug, Template)]
#[template(path = "admin/schedule_edit.html")]
struct ScheduleEditTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
visit: Visit,
clients: Vec<Client>,
users: Vec<User>,
media: Vec<Media>,
}
#[derive(Debug)]
struct MediaItem {
media: Media,
client_name: String,
visit_date: Option<String>,
}
#[derive(Debug, Template)]
#[template(path = "admin/media.html")]
struct MediaTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
items: Vec<MediaItem>,
clients: Vec<Client>,
filter_client_id: i64,
}
#[derive(Debug, Template)]
#[template(path = "admin/media_upload.html")]
struct MediaUploadTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
client_name: &'a str,
visit_id: i64,
visit_label: &'a str,
}
// ---------------------------------------------------------------------------
// Auth Handlers
// ---------------------------------------------------------------------------
#[derive(Debug, Template)]
#[template(path = "admin/setup.html")]
struct SetupTemplate<'a> {
t: &'a Translations,
lang: Lang,
error: Option<String>,
}
async fn has_any_admin(db: &Database) -> cot::Result<bool> {
let count = User::objects().count(db).await?;
Ok(count > 0)
}
async fn login_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
if get_admin_name(&session).await.is_some() {
return Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response();
}
// No admins? Show setup form instead.
if !has_any_admin(&db).await? {
return Redirect::new(format!("/admin/setup?lang={}", lang.code())).into_response();
}
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
let body = LoginTemplate {
t: lang.t(),
lang,
error: None,
turnstile_site_key,
}
.render()?;
html_response(body, lang)
}
async fn setup_page(request: Request, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
// If admins already exist, redirect to login
if has_any_admin(&db).await? {
return Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response();
}
let body = SetupTemplate {
t: lang.t(),
lang,
error: None,
}
.render()?;
html_response(body, lang)
}
#[derive(Deserialize)]
struct SetupForm {
login: String,
display_name: Option<String>,
password: String,
password_confirm: String,
}
async fn setup_submit(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let (lang, form): (_, SetupForm) = parse_form_from_request(request).await?;
// Block if admins already exist
if has_any_admin(&db).await? {
return Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response();
}
if form.password != form.password_confirm {
let body = SetupTemplate {
t: lang.t(),
lang,
error: Some(lang.t().users_error_passwords_mismatch.to_string()),
}
.render()?;
return html_response(body, lang);
}
let display = form.display_name.filter(|s| !s.trim().is_empty());
let mut user = User {
id: Auto::auto(),
login: form.login,
password_hash: password_auth::generate_hash(&form.password),
display_name: display,
telegram_chat_id: None,
telegram_notifications: Some(false),
status: "active".to_string(),
created_at: now_utc(),
updated_at: now_utc(),
};
user.save(&db).await?;
let display_name = user
.display_name
.as_deref()
.unwrap_or(&user.login)
.to_string();
session.insert(SESSION_USER_ID, user.id.unwrap()).await?;
session.insert(SESSION_USER_NAME, display_name).await?;
Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response()
}
#[derive(Deserialize)]
struct LoginForm {
login: String,
password: String,
#[serde(default, rename = "cf-turnstile-response")]
cf_turnstile_response: Option<String>,
}
async fn login_submit(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let (lang, form): (_, LoginForm) = parse_form_from_request(request).await?;
if !crate::turnstile::verify(&db, form.cf_turnstile_response.as_deref()).await? {
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
let body = LoginTemplate {
t: lang.t(),
lang,
error: Some(lang.t().login_error.to_string()),
turnstile_site_key,
}
.render()?;
return html_response(body, lang);
}
let login = form.login.clone();
let user = query!(User, $login == login && $status == "active")
.get(&db)
.await?;
if let Some(user) = user {
if password_auth::verify_password(form.password, &user.password_hash).is_ok() {
let display = user
.display_name
.as_deref()
.unwrap_or(&user.login)
.to_string();
session.insert(SESSION_USER_ID, user.id.unwrap()).await?;
session.insert(SESSION_USER_NAME, display).await?;
return Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response();
}
}
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
let body = LoginTemplate {
t: lang.t(),
lang,
error: Some(lang.t().login_error.to_string()),
turnstile_site_key,
}
.render()?;
html_response(body, lang)
}
async fn logout(request: Request, session: Session) -> cot::Result<Response> {
let lang = detect_lang(&request);
session.clear().await;
Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response()
}
// ---------------------------------------------------------------------------
// GET Handlers (protected)
// ---------------------------------------------------------------------------
async fn admin_index(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let user_id = get_admin_id(&session).await.unwrap_or(0);
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 today_visits: Vec<TodayVisit> = all_visits
.iter()
.filter(|v| v.visit_date == today && v.user_id.primary_key().unwrap() == user_id)
.map(|v| {
let cid: i64 = v.client_id.primary_key().unwrap();
let client = clients.iter().find(|c| c.id.unwrap() == cid);
TodayVisit {
client_name: client.map(|c| c.name.clone()).unwrap_or_default(),
client_phone: client.and_then(|c| c.phone.clone()).unwrap_or_default(),
client_address: client.and_then(|c| c.address.clone()).unwrap_or_default(),
client_color: client
.and_then(|c| c.color.clone())
.unwrap_or_else(|| "#7c6ed4".to_string()),
visit: v.clone(),
}
})
.collect();
today_visits.sort_by(|a, b| a.visit.time_start.cmp(&b.visit.time_start));
let mut all_feedbacks: Vec<RecentFeedback> = all_visits
.iter()
.filter(|v| {
v.user_id.primary_key().unwrap() == user_id && v.client_feedback.is_some()
})
.map(|v| {
let cid: i64 = v.client_id.primary_key().unwrap();
let client_name = clients
.iter()
.find(|c| c.id.unwrap() == cid)
.map(|c| c.name.clone())
.unwrap_or_default();
RecentFeedback {
visit_id: v.id.unwrap(),
client_name,
visit_date: v.visit_date,
feedback: v.client_feedback.clone().unwrap_or_default(),
}
})
.collect();
all_feedbacks.sort_by(|a, b| b.visit_date.cmp(&a.visit_date));
const PER_PAGE: usize = 10;
let feedback_page: usize = request
.uri()
.query()
.and_then(|q| {
q.split('&')
.find_map(|p| p.strip_prefix("page="))
.and_then(|v| v.parse().ok())
})
.unwrap_or(1)
.max(1);
let feedback_total_pages = (all_feedbacks.len() + PER_PAGE - 1).max(1) / PER_PAGE.max(1);
let feedbacks: Vec<RecentFeedback> = all_feedbacks
.into_iter()
.skip((feedback_page - 1) * PER_PAGE)
.take(PER_PAGE)
.collect();
let body = DashboardTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
today_visits,
feedbacks,
feedback_page,
feedback_total_pages,
}
.render()?;
html_response(body, lang)
}
async fn leads_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let show_all = has_query_flag(&request, "all");
let mut leads = Lead::objects().all(&db).await?;
leads.sort_by(|a, b| b.created_at.cmp(&a.created_at));
if !show_all {
leads.retain(|l| l.status == "new" || l.status == "in_progress");
}
let body = LeadsTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
leads,
show_all,
}
.render()?;
html_response(body, lang)
}
async fn clients_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let show_all = has_query_flag(&request, "all");
let clients = if show_all {
Client::objects().all(&db).await?
} else {
query!(Client, $status == "active").all(&db).await?
};
let body = ClientsTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
clients,
show_all,
}
.render()?;
html_response(body, lang)
}
async fn client_new_page(request: Request, session: Session) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let t = lang.t();
let body = ClientFormTemplate {
t,
lang,
admin_name: &admin_name,
title: t.clients_add_title,
action_url: "/admin/clients/add",
submit_label: t.clients_add_button,
client_name: "",
client_phone: "",
client_email: "",
client_address: "",
client_notes: "",
client_color: rand_client_color(),
is_edit: false,
client_id: 0,
client_status: "",
client_token: "",
}
.render()?;
html_response(body, lang)
}
async fn client_edit_page(
request: Request,
session: Session,
db: Database,
Path(client_id): Path<i64>,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let client = match query!(Client, $id == client_id).get(&db).await? {
Some(c) => c,
None => {
return Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response();
}
};
let t = lang.t();
let action_url = format!("/admin/clients/{}/save", client.id);
let cid = client.id.unwrap();
let body = ClientFormTemplate {
t,
lang,
admin_name: &admin_name,
title: t.clients_edit_title,
action_url: &action_url,
submit_label: t.clients_save,
client_name: &client.name,
client_phone: client.phone.as_deref().unwrap_or(""),
client_email: client.email.as_deref().unwrap_or(""),
client_address: client.address.as_deref().unwrap_or(""),
client_notes: client.notes.as_deref().unwrap_or(""),
client_color: client.color.as_deref().unwrap_or("#7c6ed4"),
is_edit: true,
client_id: cid,
client_status: &client.status,
client_token: &client.media_token,
}
.render()?;
html_response(body, lang)
}
async fn client_edit_submit(
request: Request,
session: Session,
db: Database,
Path(client_id): Path<i64>,
) -> cot::Result<Response> {
let (lang, form): (_, AddClientForm) = parse_form_from_request(request).await?;
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.name = form.name;
client.phone = form.phone.filter(|s| !s.trim().is_empty());
client.email = form.email.filter(|s| !s.trim().is_empty());
client.address = form.address.filter(|s| !s.trim().is_empty());
client.notes = form.notes.filter(|s| !s.trim().is_empty());
if let Some(color) = form.color.filter(|s| !s.trim().is_empty()) {
client.color = Some(color);
}
client.updated_at = now_utc();
client.save(&db).await?;
}
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
}
async fn users_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let users = User::objects().all(&db).await?;
let body = UsersTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
users,
error: None,
}
.render()?;
html_response(body, lang)
}
async fn settings_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let settings = Setting::objects().all(&db).await?;
let body = SettingsTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
settings,
saved: false,
}
.render()?;
html_response(body, lang)
}
// ---------------------------------------------------------------------------
// POST Handlers (protected)
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct AddUserForm {
login: String,
display_name: Option<String>,
password: String,
password_confirm: String,
}
async fn add_user(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let (lang, form): (_, AddUserForm) = parse_form_from_request(request).await?;
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let error = if form.password != form.password_confirm {
Some(lang.t().users_error_passwords_mismatch.to_string())
} else {
let login = form.login.clone();
let existing = query!(User, $login == login).get(&db).await?;
if existing.is_some() {
Some(lang.t().users_error_login_taken.to_string())
} else {
let display = form.display_name.filter(|s| !s.trim().is_empty());
let mut user = User {
id: Auto::auto(),
login: form.login,
password_hash: password_auth::generate_hash(&form.password),
display_name: display,
telegram_chat_id: None,
telegram_notifications: Some(false),
status: "active".to_string(),
created_at: now_utc(),
updated_at: now_utc(),
};
user.save(&db).await?;
None
}
};
if error.is_none() {
return Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response();
}
let users = User::objects().all(&db).await?;
let rendered = UsersTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
users,
error,
}
.render()?;
html_response(rendered, lang)
}
#[derive(Deserialize)]
struct SettingsForm {
telegram_bot_token: String,
contact_info: String,
pricing_info: String,
timezone: String,
site_domain: String,
seo_keywords: String,
turnstile_site_key: String,
turnstile_secret_key: String,
}
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let (lang, form): (_, SettingsForm) = parse_form_from_request(request).await?;
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
for (key, value) in [
("telegram_bot_token", form.telegram_bot_token),
("contact_info", form.contact_info),
("pricing_info", form.pricing_info),
("timezone", form.timezone),
("site_domain", form.site_domain),
("seo_keywords", form.seo_keywords),
("turnstile_site_key", form.turnstile_site_key),
("turnstile_secret_key", form.turnstile_secret_key),
] {
let k = key.to_string();
let existing = query!(Setting, $key == k).get(&db).await?;
match existing {
Some(mut s) => {
s.value = value;
s.updated_at = now_utc();
s.save(&db).await?;
}
None => {
let mut s = Setting {
id: Auto::auto(),
key: key.to_string(),
value,
updated_at: now_utc(),
};
s.save(&db).await?;
}
}
}
let settings = Setting::objects().all(&db).await?;
let rendered = SettingsTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
settings,
saved: true,
}
.render()?;
html_response(rendered, lang)
}
#[derive(Deserialize)]
struct StatusForm {
status: String,
}
async fn lead_set_status(
request: Request,
session: Session,
db: Database,
Path(lead_id): Path<i64>,
) -> cot::Result<Response> {
let (lang, form): (_, StatusForm) = parse_form_from_request(request).await?;
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
if let Some(mut lead) = query!(Lead, $id == lead_id).get(&db).await? {
lead.status = form.status;
lead.updated_at = now_utc();
lead.save(&db).await?;
}
Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response()
}
async fn lead_convert(
request: Request,
session: Session,
db: Database,
Path(lead_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 lead) = query!(Lead, $id == lead_id).get(&db).await? {
let mut client = Client {
id: Auto::auto(),
name: lead.name.clone(),
phone: lead.phone.clone(),
email: lead.email.clone(),
address: None,
notes: lead.comment.clone(),
media_token: rand_token(),
color: Some(rand_client_color().to_string()),
status: "active".to_string(),
created_at: now_utc(),
updated_at: now_utc(),
};
client.save(&db).await?;
lead.status = "converted".to_string();
lead.client_id = Some(ForeignKey::from(&client));
lead.updated_at = now_utc();
lead.save(&db).await?;
}
Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response()
}
async fn client_archive(
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 = "archived".to_string();
client.updated_at = now_utc();
client.save(&db).await?;
}
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
}
async fn client_activate(
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 = "active".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,
db: Database,
Path(user_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 user) = query!(User, $id == user_id).get(&db).await? {
user.status = "archived".to_string();
user.updated_at = now_utc();
user.save(&db).await?;
}
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
}
async fn user_activate(
request: Request,
session: Session,
db: Database,
Path(user_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 user) = query!(User, $id == user_id).get(&db).await? {
user.status = "active".to_string();
user.updated_at = now_utc();
user.save(&db).await?;
}
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
}
#[derive(Deserialize)]
struct UserTelegramForm {
telegram_chat_id: Option<String>,
telegram_notifications: Option<String>,
}
async fn user_update_telegram(
request: Request,
session: Session,
db: Database,
Path(user_id): Path<i64>,
) -> cot::Result<Response> {
let (lang, form): (_, UserTelegramForm) = parse_form_from_request(request).await?;
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
if let Some(mut user) = query!(User, $id == user_id).get(&db).await? {
user.telegram_chat_id = form.telegram_chat_id.filter(|s| !s.trim().is_empty());
user.telegram_notifications = Some(form.telegram_notifications.as_deref() == Some("true"));
user.updated_at = now_utc();
user.save(&db).await?;
}
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
}
#[derive(Deserialize)]
struct AddLeadForm {
name: String,
phone: Option<String>,
email: Option<String>,
comment: Option<String>,
}
async fn add_lead(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let (lang, form): (_, AddLeadForm) = parse_form_from_request(request).await?;
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
let mut lead = Lead {
id: Auto::auto(),
name: form.name,
phone: form.phone.filter(|s| !s.trim().is_empty()),
email: form.email.filter(|s| !s.trim().is_empty()),
comment: form.comment.filter(|s| !s.trim().is_empty()),
status: "new".to_string(),
client_id: None,
created_at: now_utc(),
updated_at: now_utc(),
};
lead.save(&db).await?;
telegram::notify_new_lead(
&db,
&lead.name,
lead.phone.as_deref(),
lead.comment.as_deref(),
)
.await;
Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response()
}
#[derive(Deserialize)]
struct AddClientForm {
name: String,
phone: Option<String>,
email: Option<String>,
address: Option<String>,
notes: Option<String>,
color: Option<String>,
}
async fn add_client(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let (lang, form): (_, AddClientForm) = parse_form_from_request(request).await?;
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
let mut client = Client {
id: Auto::auto(),
name: form.name,
phone: form.phone.filter(|s| !s.trim().is_empty()),
email: form.email.filter(|s| !s.trim().is_empty()),
address: form.address.filter(|s| !s.trim().is_empty()),
notes: form.notes.filter(|s| !s.trim().is_empty()),
media_token: rand_token(),
color: Some(rand_client_color().to_string()),
status: "active".to_string(),
created_at: now_utc(),
updated_at: now_utc(),
};
client.save(&db).await?;
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
}
// ---------------------------------------------------------------------------
// Schedule Handlers
// ---------------------------------------------------------------------------
async fn schedule_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let tz = crate::tz::load_tz(&db).await;
let body = ScheduleTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
timezone: tz.to_string(),
}
.render()?;
html_response(body, lang)
}
async fn schedule_new_page(
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let current_user_id = get_admin_id(&session).await.unwrap_or(0);
let clients = query!(Client, $status == "active").all(&db).await?;
let users = query!(User, $status == "active").all(&db).await?;
let tz = crate::tz::load_tz(&db).await;
let body = ScheduleNewTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
clients,
users,
current_user_id,
timezone: tz.to_string(),
}
.render()?;
html_response(body, lang)
}
/// JSON API: return visits for FullCalendar.
async fn schedule_events(
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
if require_auth(&session, lang).await.is_err() {
return Html::new("[]").into_response();
}
// Parse ?start=YYYY-MM-DD&end=YYYY-MM-DD
let query_str = request.uri().query().unwrap_or("");
let mut start_str = "";
let mut end_str = "";
for pair in query_str.split('&') {
if let Some(v) = pair.strip_prefix("start=") {
start_str = v;
} else if let Some(v) = pair.strip_prefix("end=") {
end_str = v;
}
}
let tz = crate::tz::load_tz(&db).await;
let tz_today = crate::tz::today_in_tz(tz);
let start_date = chrono::NaiveDate::parse_from_str(start_str, "%Y-%m-%d")
.unwrap_or_else(|_| tz_today - chrono::Duration::days(60));
let end_date = chrono::NaiveDate::parse_from_str(end_str, "%Y-%m-%d")
.unwrap_or_else(|_| tz_today + chrono::Duration::days(60));
let visits = Visit::objects().all(&db).await?;
let clients = Client::objects().all(&db).await?;
let users = User::objects().all(&db).await?;
let mut events = Vec::new();
for v in &visits {
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);
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("");
let client_address = client.and_then(|c| c.address.as_deref()).unwrap_or("");
let client_color = client.and_then(|c| c.color.as_deref()).unwrap_or("#7c6ed4");
let admin_name = user
.map(|u| u.display_name.as_deref().unwrap_or(&u.login))
.unwrap_or("?");
let (bg_color, text_color) = match v.status.as_str() {
"cancelled" => ("#ffb3b3".to_string(), "#a00"),
"completed" => (format!("{}88", client_color), "#fff"),
_ => (client_color.to_string(), "#fff"),
};
let start_dt = format!("{}T{}", v.visit_date, v.time_start);
let end_dt = format!("{}T{}", v.visit_date, v.time_end);
events.push(serde_json::json!({
"id": v.id.unwrap().to_string(),
"title": client_name,
"start": start_dt,
"end": end_dt,
"backgroundColor": bg_color,
"borderColor": bg_color,
"textColor": text_color,
"extendedProps": {
"client_name": client_name,
"client_phone": client_phone,
"client_address": client_address,
"client_color": client_color,
"admin_name": admin_name,
"time_start": v.time_start,
"time_end": v.time_end,
"notes": v.notes.as_deref().unwrap_or(""),
"status": v.status,
}
}));
}
let json = serde_json::to_string(&events).unwrap_or_else(|_| "[]".to_string());
Html::new(json)
.with_header("content-type", "application/json")
.into_response()
}
#[derive(Deserialize)]
struct DayEntry {
date: String,
time_start: String,
time_end: String,
}
#[derive(Deserialize)]
struct CreateVisitsForm {
client_id: i64,
user_id: i64,
days_json: String,
notes: Option<String>,
}
async fn schedule_create(
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let (lang, form): (_, CreateVisitsForm) = parse_form_from_request(request).await?;
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
let days: Vec<DayEntry> = serde_json::from_str(&form.days_json).unwrap_or_default();
let notes = form.notes.filter(|s| !s.trim().is_empty());
// Verify client exists
let cid = form.client_id;
let client = query!(Client, $id == cid).get(&db).await?;
if client.is_none() {
return Redirect::new(format!("/admin/schedule/new?lang={}", lang.code())).into_response();
}
for day in &days {
let visit_date = match chrono::NaiveDate::parse_from_str(&day.date, "%Y-%m-%d") {
Ok(d) => d,
Err(_) => continue,
};
let mut visit = Visit {
id: Auto::auto(),
client_id: ForeignKey::PrimaryKey(Auto::fixed(form.client_id)),
user_id: ForeignKey::PrimaryKey(Auto::fixed(form.user_id)),
visit_date,
time_start: day.time_start.clone(),
time_end: day.time_end.clone(),
notes: notes.clone(),
public_notes: None,
client_feedback: None,
status: "scheduled".to_string(),
created_at: now_utc(),
updated_at: now_utc(),
};
visit.save(&db).await?;
}
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
}
async fn schedule_edit_page(
request: Request,
session: Session,
db: Database,
Path(visit_id): Path<i64>,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let visit = match query!(Visit, $id == visit_id).get(&db).await? {
Some(v) => v,
None => {
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
}
};
let clients = query!(Client, $status == "active").all(&db).await?;
let users = query!(User, $status == "active").all(&db).await?;
let mut visit_media = Media::objects().all(&db).await?;
visit_media.retain(|m| {
m.status == "active"
&& m.visit_id
.as_ref()
.map(|fk| fk.primary_key().unwrap() == visit_id)
.unwrap_or(false)
});
visit_media.sort_by(|a, b| a.created_at.cmp(&b.created_at));
let body = ScheduleEditTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
visit,
clients,
users,
media: visit_media,
}
.render()?;
html_response(body, lang)
}
#[derive(Deserialize)]
struct EditVisitForm {
client_id: i64,
user_id: i64,
visit_date: String,
time_start: String,
time_end: String,
status: String,
notes: Option<String>,
public_notes: Option<String>,
}
async fn schedule_edit_submit(
request: Request,
session: Session,
db: Database,
Path(visit_id): Path<i64>,
) -> cot::Result<Response> {
let (lang, form): (_, EditVisitForm) = parse_form_from_request(request).await?;
if let Err(resp) = require_auth(&session, lang).await {
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));
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;
}
visit.time_start = form.time_start;
visit.time_end = form.time_end;
visit.status = form.status;
visit.notes = form.notes.filter(|s| !s.trim().is_empty());
visit.public_notes = form.public_notes.filter(|s| !s.trim().is_empty());
visit.updated_at = now_utc();
visit.save(&db).await?;
}
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
}
async fn visit_delete(
request: Request,
session: Session,
db: Database,
Path(visit_id): Path<i64>,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
query!(Visit, $id == visit_id).delete(&db).await?;
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
}
async fn visit_set_done(
request: Request,
session: Session,
db: Database,
Path(visit_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 visit) = query!(Visit, $id == visit_id).get(&db).await? {
visit.status = "completed".to_string();
visit.updated_at = now_utc();
visit.save(&db).await?;
}
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
}
async fn visit_set_cancel(
request: Request,
session: Session,
db: Database,
Path(visit_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 visit) = query!(Visit, $id == visit_id).get(&db).await? {
visit.status = "cancelled".to_string();
visit.updated_at = now_utc();
visit.save(&db).await?;
}
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
}
// ---------------------------------------------------------------------------
// Media Handlers
// ---------------------------------------------------------------------------
async fn media_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
// Parse ?client_id=N filter
let filter_client_id: i64 = request
.uri()
.query()
.and_then(|q| {
q.split('&')
.find_map(|p| p.strip_prefix("client_id="))
.and_then(|v| v.parse().ok())
})
.unwrap_or(0);
let mut media_list = Media::objects().all(&db).await?;
media_list.retain(|m| m.status == "active");
if filter_client_id > 0 {
media_list.retain(|m| m.client_id.primary_key().unwrap() == filter_client_id);
}
media_list.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let clients_all = Client::objects().all(&db).await?;
let visits_all = Visit::objects().all(&db).await?;
let items: Vec<MediaItem> = media_list
.into_iter()
.map(|m| {
let cid: i64 = m.client_id.primary_key().unwrap();
let client = clients_all.iter().find(|c| c.id.unwrap() == cid);
let visit_date = m
.visit_id
.as_ref()
.and_then(|fk| {
let vid: i64 = fk.primary_key().unwrap();
visits_all.iter().find(|v| v.id.unwrap() == vid)
})
.map(|v| v.visit_date.to_string());
MediaItem {
client_name: client.map(|c| c.name.clone()).unwrap_or_default(),
visit_date,
media: m,
}
})
.collect();
let active_clients = clients_all
.into_iter()
.filter(|c| c.status == "active")
.collect();
let body = MediaTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
items,
clients: active_clients,
filter_client_id,
}
.render()?;
html_response(body, lang)
}
async fn media_upload_page(
request: Request,
session: Session,
db: Database,
Path(visit_id): Path<i64>,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let visit = match query!(Visit, $id == visit_id).get(&db).await? {
Some(v) => v,
None => return Redirect::new(format!("/admin/?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();
let visit_label = format!(
"{} {}{}",
visit.visit_date, visit.time_start, visit.time_end
);
let body = MediaUploadTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
client_name: &client_name,
visit_id,
visit_label: &visit_label,
}
.render()?;
html_response(body, lang)
}
fn extract_boundary(request: &Request) -> Option<String> {
let ct = request.headers().get("content-type")?.to_str().ok()?;
ct.split(';').find_map(|part| {
let part = part.trim();
part.strip_prefix("boundary=")
.map(|b| b.trim_matches('"').to_string())
})
}
async fn media_upload_submit(
request: Request,
session: Session,
db: Database,
Path(visit_id): Path<i64>,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
let boundary = extract_boundary(&request)
.ok_or_else(|| cot::Error::internal("missing multipart boundary"))?;
// Get visit info
let visit = match query!(Visit, $id == visit_id).get(&db).await? {
Some(v) => v,
None => return Redirect::new(format!("/admin/?lang={}", lang.code())).into_response(),
};
let client_id: i64 = visit.client_id.primary_key().unwrap();
let bytes = request.into_body().into_bytes().await?;
let stream =
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)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let mut caption = String::new();
let mut saved_files: Vec<(String, String)> = Vec::new(); // (path, file_type)
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
{
let field_name = field.name().unwrap_or("").to_string();
if field_name == "caption" {
caption = field
.text()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
continue;
}
if field_name == "files" {
let original_name = field.file_name().unwrap_or("file").to_string();
if original_name.is_empty() {
continue;
}
let ext = original_name
.rsplit('.')
.next()
.unwrap_or("bin")
.to_lowercase();
let file_type = match ext.as_str() {
"jpg" | "jpeg" | "png" | "heic" | "heif" | "webp" => "photo",
"mp4" | "mov" | "avi" | "mkv" | "webm" => "video",
_ => continue, // skip unsupported
};
let file_id = uuid::Uuid::new_v4();
let file_path = format!("{}/{}.{}", upload_dir, file_id, ext);
let data = field
.bytes()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
if data.is_empty() {
continue;
}
tokio::fs::write(&file_path, &data)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
saved_files.push((file_path, file_type.to_string()));
}
}
let caption_opt = if caption.trim().is_empty() {
None
} else {
Some(caption)
};
for (path, ftype) in saved_files {
let mut media = Media {
id: Auto::auto(),
client_id: ForeignKey::PrimaryKey(Auto::fixed(client_id)),
visit_id: Some(ForeignKey::PrimaryKey(Auto::fixed(visit_id))),
file_path: path,
file_type: ftype,
caption: caption_opt.clone(),
status: "active".to_string(),
created_at: now_utc(),
};
media.save(&db).await?;
}
Redirect::new(format!(
"/admin/schedule/{}/edit?lang={}",
visit_id,
lang.code()
))
.into_response()
}
async fn media_delete(
request: Request,
session: Session,
db: Database,
Path(media_id): Path<i64>,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
let referer = request
.headers()
.get("referer")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
if let Some(mut m) = query!(Media, $id == media_id).get(&db).await? {
let file_path = m.file_path.clone();
m.status = "archived".to_string();
m.save(&db).await?;
let _ = tokio::fs::remove_file(&file_path).await;
}
let redirect_url = referer
.filter(|r| r.contains("/schedule/") && r.contains("/edit"))
.unwrap_or_else(|| format!("/admin/media?lang={}", lang.code()));
Redirect::new(redirect_url).into_response()
}
/// Serve uploaded files by media ID.
async fn serve_upload(
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(m) => m,
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)
}
Err(_) => Html::new("404").into_response(),
}
}
// ---------------------------------------------------------------------------
// Testimonials
// ---------------------------------------------------------------------------
#[derive(Debug, Template)]
#[template(path = "admin/testimonials.html")]
struct TestimonialsTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: String,
testimonials: Vec<Testimonial>,
}
async fn testimonials_page(
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let mut testimonials = Testimonial::objects().all(&db).await?;
testimonials.sort_by(|a, b| {
a.sort_order
.cmp(&b.sort_order)
.then(b.id.unwrap().cmp(&a.id.unwrap()))
});
let body = TestimonialsTemplate {
t: lang.t(),
lang,
admin_name,
testimonials,
}
.render()?;
html_response(body, lang)
}
async fn testimonial_add(
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
let boundary = extract_boundary(&request)
.ok_or_else(|| cot::Error::internal("missing multipart boundary"))?;
let bytes = request.into_body().into_bytes().await?;
let stream =
futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) });
let mut multipart = multer::Multipart::new(stream, boundary);
let mut text = String::new();
let mut author_note = String::new();
let mut image_path: Option<String> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
{
let field_name = field.name().unwrap_or("").to_string();
match field_name.as_str() {
"text" => {
text = field
.text()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
"author_note" => {
author_note = field
.text()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
"image" => {
let original_name = field.file_name().unwrap_or("").to_string();
if original_name.is_empty() {
continue;
}
let ext = original_name
.rsplit('.')
.next()
.unwrap_or("bin")
.to_lowercase();
match ext.as_str() {
"jpg" | "jpeg" | "png" | "webp" | "heic" | "heif" => {}
_ => continue,
}
let data = field
.bytes()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
if data.is_empty() {
continue;
}
let upload_dir = "uploads/testimonials";
tokio::fs::create_dir_all(upload_dir)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let file_id = uuid::Uuid::new_v4();
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
tokio::fs::write(&path, &data)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
image_path = Some(path);
}
_ => {}
}
}
if !text.trim().is_empty() {
let max_order: i32 = Testimonial::objects()
.all(&db)
.await?
.iter()
.map(|t| t.sort_order)
.max()
.unwrap_or(0);
let mut testimonial = Testimonial {
id: Auto::auto(),
text: text.trim().to_string(),
author_note: if author_note.trim().is_empty() {
None
} else {
Some(author_note.trim().to_string())
},
image_path,
status: "active".to_string(),
sort_order: max_order + 1,
created_at: now_utc(),
};
testimonial.save(&db).await?;
}
Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response()
}
async fn testimonial_toggle(
request: Request,
session: Session,
db: Database,
Path(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 t) = query!(Testimonial, $id == id).get(&db).await? {
t.status = if t.status == "active" {
"hidden".to_string()
} else {
"active".to_string()
};
t.save(&db).await?;
}
Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response()
}
async fn testimonial_delete(
request: Request,
session: Session,
db: Database,
Path(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 t) = query!(Testimonial, $id == id).get(&db).await? {
t.status = "deleted".to_string();
t.save(&db).await?;
}
Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response()
}
async fn testimonial_edit(
request: Request,
session: Session,
db: Database,
Path(id): Path<i64>,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
let boundary = extract_boundary(&request)
.ok_or_else(|| cot::Error::internal("missing multipart boundary"))?;
let bytes = request.into_body().into_bytes().await?;
let stream =
futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) });
let mut multipart = multer::Multipart::new(stream, boundary);
let mut text = String::new();
let mut author_note = String::new();
let mut new_image_path: Option<String> = None;
let mut remove_image = false;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
{
let field_name = field.name().unwrap_or("").to_string();
match field_name.as_str() {
"text" => {
text = field
.text()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
"author_note" => {
author_note = field
.text()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
"remove_image" => {
let val = field
.text()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
remove_image = val == "1";
}
"image" => {
let original_name = field.file_name().unwrap_or("").to_string();
if original_name.is_empty() {
continue;
}
let ext = original_name
.rsplit('.')
.next()
.unwrap_or("bin")
.to_lowercase();
match ext.as_str() {
"jpg" | "jpeg" | "png" | "webp" | "heic" | "heif" => {}
_ => continue,
}
let data = field
.bytes()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
if data.is_empty() {
continue;
}
let upload_dir = "uploads/testimonials";
tokio::fs::create_dir_all(upload_dir)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let file_id = uuid::Uuid::new_v4();
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
tokio::fs::write(&path, &data)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
new_image_path = Some(path);
}
_ => {}
}
}
if let Some(mut t) = query!(Testimonial, $id == id).get(&db).await? {
if !text.trim().is_empty() {
t.text = text.trim().to_string();
}
t.author_note = if author_note.trim().is_empty() {
None
} else {
Some(author_note.trim().to_string())
};
if let Some(path) = new_image_path {
t.image_path = Some(path);
} else if remove_image {
t.image_path = None;
}
t.save(&db).await?;
}
Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response()
}
/// Serve testimonial images (public, no auth needed for landing page).
async fn serve_testimonial_image(
_request: Request,
db: Database,
Path(id): Path<i64>,
) -> cot::Result<Response> {
let testimonial = match query!(Testimonial, $id == id).get(&db).await? {
Some(t) => t,
None => return Html::new("404").into_response(),
};
let path = match &testimonial.image_path {
Some(p) => p.clone(),
None => return Html::new("404").into_response(),
};
match tokio::fs::read(&path).await {
Ok(data) => {
let content_type = match path.rsplit('.').next().unwrap_or("") {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"webp" => "image/webp",
"heic" | "heif" => "image/heic",
_ => "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)
}
Err(_) => Html::new("404").into_response(),
}
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
pub fn admin_router() -> Router {
Router::with_urls([
// Auth (public)
Route::with_handler_and_name("/login", login_page, "admin-login"),
Route::with_handler_and_name("/login/submit", login_submit, "admin-login-submit"),
Route::with_handler_and_name("/logout", logout, "admin-logout"),
Route::with_handler_and_name("/setup", setup_page, "admin-setup"),
Route::with_handler_and_name("/setup/submit", setup_submit, "admin-setup-submit"),
// Protected
Route::with_handler_and_name("", admin_index, "admin-index-bare"),
Route::with_handler_and_name("/", admin_index, "admin-index"),
Route::with_handler_and_name("/leads", leads_page, "admin-leads"),
Route::with_handler_and_name(
"/leads/{lead_id}/status",
lead_set_status,
"admin-lead-status",
),
Route::with_handler_and_name(
"/leads/{lead_id}/convert",
lead_convert,
"admin-lead-convert",
),
Route::with_handler_and_name("/leads/add", add_lead, "admin-lead-add"),
Route::with_handler_and_name("/clients", clients_page, "admin-clients"),
Route::with_handler_and_name("/clients/new", client_new_page, "admin-client-new"),
Route::with_handler_and_name("/clients/add", add_client, "admin-client-add"),
Route::with_handler_and_name(
"/clients/{client_id}/edit",
client_edit_page,
"admin-client-edit",
),
Route::with_handler_and_name(
"/clients/{client_id}/save",
client_edit_submit,
"admin-client-edit-submit",
),
Route::with_handler_and_name(
"/clients/{client_id}/archive",
client_archive,
"admin-client-archive",
),
Route::with_handler_and_name(
"/clients/{client_id}/activate",
client_activate,
"admin-client-activate",
),
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"),
Route::with_handler_and_name("/schedule/create", schedule_create, "admin-schedule-create"),
Route::with_handler_and_name(
"/schedule/{visit_id}/edit",
schedule_edit_page,
"admin-visit-edit",
),
Route::with_handler_and_name(
"/schedule/{visit_id}/save",
schedule_edit_submit,
"admin-visit-save",
),
Route::with_handler_and_name(
"/schedule/{visit_id}/delete",
visit_delete,
"admin-visit-delete",
),
Route::with_handler_and_name(
"/schedule/{visit_id}/done",
visit_set_done,
"admin-visit-done",
),
Route::with_handler_and_name(
"/schedule/{visit_id}/cancel",
visit_set_cancel,
"admin-visit-cancel",
),
Route::with_handler_and_name("/users", users_page, "admin-users"),
Route::with_handler_and_name("/users/add", add_user, "admin-user-add"),
Route::with_handler_and_name(
"/users/{user_id}/archive",
user_archive,
"admin-user-archive",
),
Route::with_handler_and_name(
"/users/{user_id}/activate",
user_activate,
"admin-user-activate",
),
Route::with_handler_and_name(
"/users/{user_id}/telegram",
user_update_telegram,
"admin-user-telegram",
),
Route::with_handler_and_name("/media", media_page, "admin-media"),
Route::with_handler_and_name(
"/media/{visit_id}/upload",
media_upload_page,
"admin-media-upload",
),
Route::with_handler_and_name(
"/media/{visit_id}/upload/submit",
media_upload_submit,
"admin-media-upload-submit",
),
Route::with_handler_and_name(
"/media/{media_id}/delete",
media_delete,
"admin-media-delete",
),
Route::with_handler_and_name("/uploads/{media_id}", serve_upload, "admin-uploads"),
Route::with_handler_and_name("/testimonials", testimonials_page, "admin-testimonials"),
Route::with_handler_and_name(
"/testimonials/add",
testimonial_add,
"admin-testimonial-add",
),
Route::with_handler_and_name(
"/testimonials/{id}/toggle",
testimonial_toggle,
"admin-testimonial-toggle",
),
Route::with_handler_and_name(
"/testimonials/{id}/delete",
testimonial_delete,
"admin-testimonial-delete",
),
Route::with_handler_and_name(
"/testimonials/{id}/edit",
testimonial_edit,
"admin-testimonial-edit",
),
Route::with_handler_and_name(
"/testimonials/{id}/image",
serve_testimonial_image,
"admin-testimonial-image",
),
Route::with_handler_and_name("/settings", settings_page, "admin-settings-get"),
Route::with_handler_and_name("/settings/save", save_settings, "admin-settings-save"),
])
}