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 { Html::new(body) .with_header("set-cookie", lang_cookie(lang)) .into_response() } async fn parse_form_from_request( 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 { session .get::(SESSION_USER_NAME) .await .ok() .flatten() } /// Get admin user ID from session. async fn get_admin_id(session: &Session) -> Option { session.get::(SESSION_USER_ID).await.ok().flatten() } /// Redirect to login if not authenticated. async fn require_auth(session: &Session, lang: Lang) -> Result { 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, feedbacks: Vec, 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, } #[derive(Debug, Template)] #[template(path = "admin/leads.html")] struct LeadsTemplate<'a> { t: &'a Translations, lang: Lang, admin_name: &'a str, leads: Vec, 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, 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, error: Option, } #[derive(Debug, Template)] #[template(path = "admin/settings.html")] struct SettingsTemplate<'a> { t: &'a Translations, lang: Lang, admin_name: &'a str, settings: Vec, 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, users: Vec, current_user_id: i64, } #[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, users: Vec, media: Vec, } #[derive(Debug)] struct MediaItem { media: Media, client_name: String, visit_date: Option, } #[derive(Debug, Template)] #[template(path = "admin/media.html")] struct MediaTemplate<'a> { t: &'a Translations, lang: Lang, admin_name: &'a str, items: Vec, clients: Vec, 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, } async fn has_any_admin(db: &Database) -> cot::Result { let count = User::objects().count(db).await?; Ok(count > 0) } async fn login_page(request: Request, session: Session, db: Database) -> cot::Result { 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 body = LoginTemplate { t: lang.t(), lang, error: None, } .render()?; html_response(body, lang) } async fn setup_page(request: Request, db: Database) -> cot::Result { 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, password: String, password_confirm: String, } async fn setup_submit(request: Request, session: Session, db: Database) -> cot::Result { 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, 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, } async fn login_submit(request: Request, session: Session, db: Database) -> cot::Result { let (lang, form): (_, LoginForm) = parse_form_from_request(request).await?; 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 body = LoginTemplate { t: lang.t(), lang, error: Some(lang.t().login_error.to_string()), } .render()?; html_response(body, lang) } async fn logout(request: Request, session: Session) -> cot::Result { 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 { 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 = 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 = 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 = 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 { 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 { 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 { 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, ) -> cot::Result { 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, ) -> cot::Result { 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 { 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 { 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, password: String, password_confirm: String, } async fn add_user(request: Request, session: Session, db: Database) -> cot::Result { 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, 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, telegram_chat_id: String, contact_info: String, pricing_info: String, timezone: String, site_domain: String, } async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result { 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), ("telegram_chat_id", form.telegram_chat_id), ("contact_info", form.contact_info), ("pricing_info", form.pricing_info), ("timezone", form.timezone), ("site_domain", form.site_domain), ] { 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, ) -> cot::Result { 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, ) -> cot::Result { 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, ) -> cot::Result { 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, ) -> cot::Result { 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, ) -> cot::Result { 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, ) -> cot::Result { 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 AddLeadForm { name: String, phone: Option, email: Option, comment: Option, } async fn add_lead(request: Request, session: Session, db: Database) -> cot::Result { 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, email: Option, address: Option, notes: Option, color: Option, } async fn add_client(request: Request, session: Session, db: Database) -> cot::Result { 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 { 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 { 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 body = ScheduleNewTemplate { t: lang.t(), lang, admin_name: &admin_name, clients, users, current_user_id, } .render()?; html_response(body, lang) } /// JSON API: return visits for FullCalendar. async fn schedule_events( request: Request, session: Session, db: Database, ) -> cot::Result { 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" => ("#ccc".to_string(), "#666"), "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, } async fn schedule_create( request: Request, session: Session, db: Database, ) -> cot::Result { 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 = 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, ) -> cot::Result { 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, public_notes: Option, } async fn schedule_edit_submit( request: Request, session: Session, db: Database, Path(visit_id): Path, ) -> cot::Result { 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, ) -> cot::Result { 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, ) -> cot::Result { 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, ) -> cot::Result { 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 { 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 = 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, ) -> cot::Result { 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 { 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, ) -> cot::Result { 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, ) -> cot::Result { 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? { m.status = "archived".to_string(); m.save(&db).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, ) -> cot::Result { 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, } async fn testimonials_page( request: Request, session: Session, db: Database, ) -> cot::Result { 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 { 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 = 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, ) -> cot::Result { 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, ) -> cot::Result { 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, ) -> cot::Result { 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 = 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, ) -> cot::Result { 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("/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"), ]) }