2189 lines
68 KiB
Rust
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"),
|
|
])
|
|
}
|