init
Build and Publish / Build and Publish Docker Image (push) Successful in 1m12s

This commit is contained in:
Ultradesu
2026-04-29 17:49:07 +03:00
commit ff32e6bbaf
36 changed files with 9595 additions and 0 deletions
+1711
View File
File diff suppressed because it is too large Load Diff
+637
View File
@@ -0,0 +1,637 @@
/// Supported languages.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lang {
Ru,
En,
}
impl Lang {
pub fn code(self) -> &'static str {
match self {
Lang::Ru => "ru",
Lang::En => "en",
}
}
pub fn from_code(code: &str) -> Option<Self> {
match code {
"ru" => Some(Lang::Ru),
"en" => Some(Lang::En),
_ => None,
}
}
/// Parse Accept-Language header and pick best match.
pub fn from_accept_language(header: &str) -> Self {
for part in header.split(',') {
let tag = part.split(';').next().unwrap_or("").trim().to_lowercase();
if tag.starts_with("ru") {
return Lang::Ru;
}
if tag.starts_with("en") {
return Lang::En;
}
}
Lang::En
}
pub fn t(self) -> &'static Translations {
match self {
Lang::Ru => &RU,
Lang::En => &EN,
}
}
/// The other language (for the switcher).
pub fn other(self) -> Self {
match self {
Lang::Ru => Lang::En,
Lang::En => Lang::Ru,
}
}
pub fn label(self) -> &'static str {
match self {
Lang::Ru => "Русский",
Lang::En => "English",
}
}
}
#[derive(Debug)]
#[allow(dead_code)]
pub struct Translations {
// Nav
pub nav_leads: &'static str,
pub nav_clients: &'static str,
pub nav_visits: &'static str,
pub nav_users: &'static str,
pub nav_settings: &'static str,
pub nav_title: &'static str,
// Leads
pub leads_title: &'static str,
pub leads_empty: &'static str,
pub leads_name: &'static str,
pub leads_phone: &'static str,
pub leads_email: &'static str,
pub leads_comment: &'static str,
pub leads_status: &'static str,
pub leads_created: &'static str,
pub leads_actions: &'static str,
// Lead statuses
pub status_new: &'static str,
pub status_in_progress: &'static str,
pub status_converted: &'static str,
pub status_rejected: &'static str,
// Leads (add)
pub leads_add_title: &'static str,
pub leads_add_button: &'static str,
// Clients
pub clients_title: &'static str,
pub clients_empty: &'static str,
pub clients_name: &'static str,
pub clients_phone: &'static str,
pub clients_email: &'static str,
pub clients_address: &'static str,
pub clients_notes: &'static str,
pub clients_status: &'static str,
pub clients_created: &'static str,
pub clients_media_link: &'static str,
pub clients_add_title: &'static str,
pub clients_add_button: &'static str,
pub client_status_active: &'static str,
pub client_status_archived: &'static str,
// Users
pub users_title: &'static str,
pub users_login: &'static str,
pub users_display_name: &'static str,
pub users_status: &'static str,
pub users_created: &'static str,
pub users_password: &'static str,
pub users_password_confirm: &'static str,
pub users_add_title: &'static str,
pub users_add_button: &'static str,
pub users_error_passwords_mismatch: &'static str,
pub users_error_login_taken: &'static str,
// Settings
pub settings_title: &'static str,
pub settings_key: &'static str,
pub settings_value: &'static str,
pub settings_save: &'static str,
pub settings_saved: &'static str,
pub settings_empty: &'static str,
pub settings_telegram_bot_token: &'static str,
pub settings_telegram_chat_id: &'static str,
pub settings_contact_info: &'static str,
pub landing_contact_label: &'static str,
// Dashboard
pub dashboard_title: &'static str,
pub dashboard_today_visits: &'static str,
pub dashboard_no_visits: &'static str,
pub dashboard_recent_feedbacks: &'static str,
pub dashboard_no_feedbacks: &'static str,
// Login / Setup
pub login_title: &'static str,
pub login_button: &'static str,
pub login_error: &'static str,
pub logout: &'static str,
pub setup_title: &'static str,
pub setup_description: &'static str,
pub setup_button: &'static str,
// Landing page
pub landing_meta_description: &'static str,
pub landing_hero_title: &'static str,
pub landing_hero_subtitle: &'static str,
pub landing_hero_cta: &'static str,
pub landing_services_title: &'static str,
pub landing_service_cats_title: &'static str,
pub landing_service_cats_text: &'static str,
pub landing_service_dogs_title: &'static str,
pub landing_service_dogs_text: &'static str,
pub landing_service_home_title: &'static str,
pub landing_service_home_text: &'static str,
pub landing_how_title: &'static str,
pub landing_how_step1_title: &'static str,
pub landing_how_step1_text: &'static str,
pub landing_how_step2_title: &'static str,
pub landing_how_step2_text: &'static str,
pub landing_how_step3_title: &'static str,
pub landing_how_step3_text: &'static str,
pub landing_form_title: &'static str,
pub landing_form_subtitle: &'static str,
pub landing_form_name: &'static str,
pub landing_form_phone: &'static str,
pub landing_form_comment: &'static str,
pub landing_form_comment_placeholder: &'static str,
pub landing_form_submit: &'static str,
pub landing_thank_you_title: &'static str,
pub landing_thank_you_text: &'static str,
pub landing_thank_you_back: &'static str,
pub landing_footer_text: &'static str,
// Client edit
pub clients_edit_title: &'static str,
pub clients_save: &'static str,
pub clients_color: &'static str,
// Filters
pub filter_show_all: &'static str,
pub filter_show_active: &'static str,
// Schedule
pub nav_schedule: &'static str,
pub schedule_title: &'static str,
pub schedule_new: &'static str,
pub schedule_new_title: &'static str,
pub schedule_client: &'static str,
pub schedule_admin: &'static str,
pub schedule_default_time: &'static str,
pub schedule_time_start: &'static str,
pub schedule_time_end: &'static str,
pub schedule_pick_dates: &'static str,
pub schedule_range_from: &'static str,
pub schedule_range_to: &'static str,
pub schedule_fill_range: &'static str,
pub schedule_selected_days: &'static str,
pub schedule_no_days: &'static str,
pub schedule_notes: &'static str,
pub schedule_public_notes: &'static str,
pub schedule_client_feedback: &'static str,
pub schedule_create: &'static str,
pub schedule_remove_day: &'static str,
pub visit_status_scheduled: &'static str,
pub visit_status_completed: &'static str,
pub visit_status_cancelled: &'static str,
pub schedule_mark_done: &'static str,
pub schedule_cancel: &'static str,
pub schedule_edit_title: &'static str,
pub schedule_date: &'static str,
pub schedule_status: &'static str,
pub schedule_save: &'static str,
pub schedule_delete: &'static str,
pub schedule_delete_confirm: &'static str,
// Media
pub nav_media: &'static str,
pub media_title: &'static str,
pub media_upload: &'static str,
pub media_upload_title: &'static str,
pub media_caption: &'static str,
pub media_choose_files: &'static str,
pub media_empty: &'static str,
pub media_delete: &'static str,
pub media_delete_confirm: &'static str,
pub media_all_clients: &'static str,
// Client portal
pub portal_title: &'static str,
pub portal_upcoming: &'static str,
pub portal_past: &'static str,
pub portal_no_upcoming: &'static str,
pub portal_no_past: &'static str,
pub portal_photos: &'static str,
pub portal_feedback_placeholder: &'static str,
pub portal_feedback_submit: &'static str,
pub portal_feedback_thanks: &'static str,
pub portal_link: &'static str,
// Common
pub no_value: &'static str,
pub action_convert: &'static str,
pub action_reject: &'static str,
pub action_in_progress: &'static str,
pub action_archive: &'static str,
pub action_activate: &'static str,
}
static RU: Translations = Translations {
nav_leads: "Заявки",
nav_clients: "Клиенты",
nav_visits: "Визиты",
nav_users: "Админы",
nav_settings: "Настройки",
nav_title: "Пет-ситтинг",
leads_title: "Заявки",
leads_empty: "Заявок пока нет.",
leads_name: "Имя",
leads_phone: "Телефон",
leads_email: "Email",
leads_comment: "Комментарий",
leads_status: "Статус",
leads_created: "Создана",
leads_actions: "Действия",
status_new: "Новая",
status_in_progress: "В работе",
status_converted: "Конвертирована",
status_rejected: "Отклонена",
leads_add_title: "Добавить заявку",
leads_add_button: "Добавить",
clients_title: "Клиенты",
clients_empty: "Клиентов пока нет.",
clients_name: "Имя",
clients_phone: "Телефон",
clients_email: "Email",
clients_address: "Адрес",
clients_notes: "Заметки",
clients_status: "Статус",
clients_created: "Добавлен",
clients_media_link: "Медиа",
clients_add_title: "Добавить клиента",
clients_add_button: "Добавить",
client_status_active: "Активный",
client_status_archived: "Архив",
users_title: "Администраторы",
users_login: "Логин",
users_display_name: "Отображаемое имя",
users_status: "Статус",
users_created: "Создан",
users_password: "Пароль",
users_password_confirm: "Подтверждение пароля",
users_add_title: "Добавить администратора",
users_add_button: "Добавить",
users_error_passwords_mismatch: "Пароли не совпадают.",
users_error_login_taken: "Этот логин уже занят.",
settings_title: "Настройки",
settings_key: "Параметр",
settings_value: "Значение",
settings_save: "Сохранить",
settings_saved: "Сохранено!",
settings_empty: "Настройки не заданы.",
settings_telegram_bot_token: "Токен Telegram бота",
settings_telegram_chat_id: "Chat ID для уведомлений",
settings_contact_info: "Контактная информация (отображается на лендинге)",
landing_contact_label: "Или свяжитесь с нами напрямую",
dashboard_title: "Главная",
dashboard_today_visits: "Визиты на сегодня",
dashboard_no_visits: "На сегодня визитов нет.",
dashboard_recent_feedbacks: "Недавние отзывы клиентов",
dashboard_no_feedbacks: "Новых отзывов нет.",
nav_media: "Медиа",
media_title: "Медиа",
media_upload: "Загрузить",
media_upload_title: "Загрузить медиа",
media_caption: "Подпись",
media_choose_files: "Выберите файлы",
media_empty: "Медиа нет.",
media_delete: "Удалить",
media_delete_confirm: "Удалить этот файл?",
media_all_clients: "Все клиенты",
portal_title: "Мои визиты",
portal_upcoming: "Предстоящие визиты",
portal_past: "Прошлые визиты",
portal_no_upcoming: "Нет предстоящих визитов.",
portal_no_past: "Прошлых визитов пока нет.",
portal_photos: "Фото и видео",
portal_feedback_placeholder: "Оставьте отзыв о визите...",
portal_feedback_submit: "Отправить",
portal_feedback_thanks: "Спасибо за отзыв!",
portal_link: "Ссылка клиента",
login_title: "Вход в систему",
login_button: "Войти",
login_error: "Неверный логин или пароль.",
logout: "Выйти",
setup_title: "Создание администратора",
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
setup_button: "Создать и войти",
clients_edit_title: "Редактировать клиента",
clients_save: "Сохранить",
clients_color: "Цвет в календаре",
filter_show_all: "Показать все",
filter_show_active: "Только активные",
nav_schedule: "Расписание",
schedule_title: "Расписание",
schedule_new: "Новые визиты",
schedule_new_title: "Запланировать визиты",
schedule_client: "Клиент",
schedule_admin: "Исполнитель",
schedule_default_time: "Время по умолчанию",
schedule_time_start: "С",
schedule_time_end: "До",
schedule_pick_dates: "Добавить дату",
schedule_range_from: "С",
schedule_range_to: "По",
schedule_fill_range: "Заполнить диапазон",
schedule_selected_days: "Выбранные дни",
schedule_no_days: "Дни не выбраны",
schedule_notes: "Приватные заметки",
schedule_public_notes: "Комментарий для клиента",
schedule_client_feedback: "Отзыв клиента",
schedule_create: "Создать визиты",
schedule_remove_day: "Убрать",
visit_status_scheduled: "Запланирован",
visit_status_completed: "Выполнен",
visit_status_cancelled: "Отменён",
schedule_mark_done: "Выполнен",
schedule_cancel: "Отменить",
schedule_edit_title: "Редактировать визит",
schedule_date: "Дата",
schedule_status: "Статус",
schedule_save: "Сохранить",
schedule_delete: "Удалить визит",
schedule_delete_confirm: "Точно удалить этот визит?",
landing_meta_description: "Профессиональный пет-ситтинг: кормление кошек, выгул собак, уход за питомцами пока вы в отпуске. Оставьте заявку — позаботимся о вашем любимце!",
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома",
landing_hero_subtitle: "Кормление кошек, выгул собак, ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке",
landing_hero_cta: "Оставить заявку",
landing_services_title: "Наши услуги",
landing_service_cats_title: "Кормление кошек",
landing_service_cats_text: "Приедем к вам домой, покормим кошку, поменяем воду и лоток, поиграем и проверим, что всё в порядке",
landing_service_dogs_title: "Выгул собак",
landing_service_dogs_text: "Погуляем с вашей собакой по привычному маршруту, покормим и проследим за самочувствием питомца",
landing_service_home_title: "Домашние визиты",
landing_service_home_text: "Регулярные визиты к вам домой: проверим питомца, польём цветы, заберём почту — всё будет как при вас",
landing_how_title: "Как это работает",
landing_how_step1_title: "Оставьте заявку",
landing_how_step1_text: "Заполните форму ниже — укажите имя и телефон. Мы свяжемся с вами в течение часа",
landing_how_step2_title: "Обсудим детали",
landing_how_step2_text: "Познакомимся с вашим питомцем, обсудим расписание визитов и особые пожелания",
landing_how_step3_title: "Заботимся о питомце",
landing_how_step3_text: "Пока вас нет — мы рядом. После каждого визита отправим фото и отчёт о самочувствии",
landing_form_title: "Оставить заявку",
landing_form_subtitle: "Расскажите о себе, и мы свяжемся с вами в ближайшее время",
landing_form_name: "Ваше имя",
landing_form_phone: "Телефон",
landing_form_comment: "Комментарий",
landing_form_comment_placeholder: "Расскажите о питомце и когда нужна помощь...",
landing_form_submit: "Отправить заявку",
landing_thank_you_title: "Спасибо за заявку!",
landing_thank_you_text: "Мы получили вашу заявку и свяжемся с вами в ближайшее время.",
landing_thank_you_back: "Вернуться на главную",
landing_footer_text: "Пет-ситтинг — забота о вашем питомце",
no_value: "",
action_convert: "Конвертировать",
action_reject: "Отклонить",
action_in_progress: "В работу",
action_archive: "В архив",
action_activate: "Активировать",
};
static EN: Translations = Translations {
nav_leads: "Leads",
nav_clients: "Clients",
nav_visits: "Visits",
nav_users: "Admins",
nav_settings: "Settings",
nav_title: "Pet Sitting",
leads_title: "Leads",
leads_empty: "No leads yet.",
leads_name: "Name",
leads_phone: "Phone",
leads_email: "Email",
leads_comment: "Comment",
leads_status: "Status",
leads_created: "Created",
leads_actions: "Actions",
status_new: "New",
status_in_progress: "In Progress",
status_converted: "Converted",
status_rejected: "Rejected",
leads_add_title: "Add Lead",
leads_add_button: "Add",
clients_title: "Clients",
clients_empty: "No clients yet.",
clients_name: "Name",
clients_phone: "Phone",
clients_email: "Email",
clients_address: "Address",
clients_notes: "Notes",
clients_status: "Status",
clients_created: "Created",
clients_media_link: "Media",
clients_add_title: "Add Client",
clients_add_button: "Add",
client_status_active: "Active",
client_status_archived: "Archived",
users_title: "Administrators",
users_login: "Login",
users_display_name: "Display Name",
users_status: "Status",
users_created: "Created",
users_password: "Password",
users_password_confirm: "Confirm Password",
users_add_title: "Add Administrator",
users_add_button: "Add",
users_error_passwords_mismatch: "Passwords do not match.",
users_error_login_taken: "This login is already taken.",
settings_title: "Settings",
settings_key: "Parameter",
settings_value: "Value",
settings_save: "Save",
settings_saved: "Saved!",
settings_empty: "No settings configured.",
settings_telegram_bot_token: "Telegram Bot Token",
settings_telegram_chat_id: "Notification Chat ID",
settings_contact_info: "Contact info (shown on landing page)",
landing_contact_label: "Or contact us directly",
dashboard_title: "Home",
dashboard_today_visits: "Today's visits",
dashboard_no_visits: "No visits for today.",
dashboard_recent_feedbacks: "Recent client feedback",
dashboard_no_feedbacks: "No recent feedback.",
nav_media: "Media",
media_title: "Media",
media_upload: "Upload",
media_upload_title: "Upload Media",
media_caption: "Caption",
media_choose_files: "Choose files",
media_empty: "No media yet.",
media_delete: "Delete",
media_delete_confirm: "Delete this file?",
media_all_clients: "All clients",
portal_title: "My Visits",
portal_upcoming: "Upcoming visits",
portal_past: "Past visits",
portal_no_upcoming: "No upcoming visits.",
portal_no_past: "No past visits yet.",
portal_photos: "Photos & Videos",
portal_feedback_placeholder: "Leave feedback about this visit...",
portal_feedback_submit: "Submit",
portal_feedback_thanks: "Thank you for your feedback!",
portal_link: "Client link",
login_title: "Sign In",
login_button: "Sign In",
login_error: "Invalid login or password.",
logout: "Sign Out",
setup_title: "Create Administrator",
setup_description: "There are no administrators yet. Create the first one to get started.",
setup_button: "Create & Sign In",
clients_edit_title: "Edit Client",
clients_save: "Save",
clients_color: "Calendar color",
filter_show_all: "Show all",
filter_show_active: "Active only",
nav_schedule: "Schedule",
schedule_title: "Schedule",
schedule_new: "New Visits",
schedule_new_title: "Plan Visits",
schedule_client: "Client",
schedule_admin: "Assigned to",
schedule_default_time: "Default Time",
schedule_time_start: "From",
schedule_time_end: "To",
schedule_pick_dates: "Add date",
schedule_range_from: "From",
schedule_range_to: "To",
schedule_fill_range: "Fill range",
schedule_selected_days: "Selected days",
schedule_no_days: "No days selected",
schedule_notes: "Private notes",
schedule_public_notes: "Note for client",
schedule_client_feedback: "Client feedback",
schedule_create: "Create visits",
schedule_remove_day: "Remove",
visit_status_scheduled: "Scheduled",
visit_status_completed: "Completed",
visit_status_cancelled: "Cancelled",
schedule_mark_done: "Done",
schedule_cancel: "Cancel",
schedule_edit_title: "Edit Visit",
schedule_date: "Date",
schedule_status: "Status",
schedule_save: "Save",
schedule_delete: "Delete visit",
schedule_delete_confirm: "Are you sure you want to delete this visit?",
landing_meta_description: "Professional pet sitting: cat feeding, dog walking, home visits while you're away. Leave a request — we'll take care of your pet!",
landing_hero_title: "We'll take care of your pet while you're away",
landing_hero_subtitle: "Cat feeding, dog walking, daily visits — your pet is in safe hands while you're on vacation or a business trip",
landing_hero_cta: "Leave a Request",
landing_services_title: "Our Services",
landing_service_cats_title: "Cat Feeding",
landing_service_cats_text: "We'll visit your home, feed the cat, change water and litter, play and make sure everything is fine",
landing_service_dogs_title: "Dog Walking",
landing_service_dogs_text: "We'll walk your dog on their usual route, feed them and keep an eye on their well-being",
landing_service_home_title: "Home Visits",
landing_service_home_text: "Regular home visits: check on your pet, water the plants, collect mail — everything as if you were home",
landing_how_title: "How It Works",
landing_how_step1_title: "Leave a Request",
landing_how_step1_text: "Fill out the form below — just your name and phone. We'll contact you within an hour",
landing_how_step2_title: "Discuss the Details",
landing_how_step2_text: "We'll meet your pet, discuss the visit schedule and any special requirements",
landing_how_step3_title: "We Take Care",
landing_how_step3_text: "While you're away — we're here. After each visit we'll send photos and a wellness report",
landing_form_title: "Leave a Request",
landing_form_subtitle: "Tell us about yourself and we'll get back to you shortly",
landing_form_name: "Your Name",
landing_form_phone: "Phone",
landing_form_comment: "Comment",
landing_form_comment_placeholder: "Tell us about your pet and when you need help...",
landing_form_submit: "Submit Request",
landing_thank_you_title: "Thank you!",
landing_thank_you_text: "We've received your request and will contact you shortly.",
landing_thank_you_back: "Back to Home",
landing_footer_text: "Pet Sitting — caring for your pet",
no_value: "",
action_convert: "Convert",
action_reject: "Reject",
action_in_progress: "In Progress",
action_archive: "Archive",
action_activate: "Activate",
};
impl Translations {
pub fn lead_status(&self, status: &str) -> &'static str {
match status {
"new" => self.status_new,
"in_progress" => self.status_in_progress,
"converted" => self.status_converted,
"rejected" => self.status_rejected,
_ => "?",
}
}
pub fn visit_status(&self, status: &str) -> &'static str {
match status {
"scheduled" => self.visit_status_scheduled,
"completed" => self.visit_status_completed,
"cancelled" => self.visit_status_cancelled,
_ => "?",
}
}
pub fn client_status(&self, status: &str) -> &'static str {
match status {
"active" => self.client_status_active,
"archived" => self.client_status_archived,
_ => "?",
}
}
}
+100
View File
@@ -0,0 +1,100 @@
mod admin;
mod i18n;
mod migrations;
pub mod models;
mod public;
mod telegram;
use cot::cli::CliMetadata;
use cot::config::{
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
SessionStoreTypeConfig,
};
use cot::db::migrations::SyncDynMigration;
use cot::middleware::SessionMiddleware;
use cot::project::{MiddlewareContext, RegisterAppsContext, RootHandler};
use cot::router::Router;
use cot::session::db::SessionApp;
use cot::{App, AppBuilder, Project};
struct PettingApp;
impl App for PettingApp {
fn name(&self) -> &'static str {
"web-petting"
}
fn migrations(&self) -> Vec<Box<SyncDynMigration>> {
cot::db::migrations::wrap_migrations(migrations::MIGRATIONS)
}
fn router(&self) -> Router {
admin::admin_router()
}
}
struct PublicApp;
impl App for PublicApp {
fn name(&self) -> &'static str {
"public"
}
fn router(&self) -> Router {
public::public_router()
}
}
struct PettingProject;
impl Project for PettingProject {
fn cli_metadata(&self) -> CliMetadata {
cot::cli::metadata!()
}
fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
Ok(ProjectConfig::builder()
.debug(true)
.database(
DatabaseConfig::builder()
.url("sqlite://db.sqlite3?mode=rwc")
.build(),
)
.middlewares(
MiddlewareConfig::builder()
.session(
SessionMiddlewareConfig::builder()
.secure(false)
.store(
SessionStoreConfig::builder()
.store_type(SessionStoreTypeConfig::Database)
.build(),
)
.build(),
)
.build(),
)
.build())
}
fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) {
apps.register(SessionApp::new());
apps.register_with_views(PublicApp, "");
apps.register_with_views(PettingApp, "/admin");
}
fn middlewares(
&self,
handler: cot::project::RootHandlerBuilder,
context: &MiddlewareContext,
) -> RootHandler {
handler
.middleware(SessionMiddleware::from_context(context))
.build()
}
}
#[cot::main]
fn main() -> impl Project {
PettingProject
}
+15
View File
@@ -0,0 +1,15 @@
//! List of migrations for the current app.
//!
//! Generated by cot CLI 0.6.0 on 2026-04-29 10:36:47+00:00
pub mod m_0001_initial;
pub mod m_0002_visit_schedule;
pub mod m_0003_visit_feedback;
pub mod m_0004_visit_public_notes;
/// The list of migrations for current app.
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
&m_0001_initial::Migration,
&m_0002_visit_schedule::Migration,
&m_0003_visit_feedback::Migration,
&m_0004_visit_public_notes::Migration,
];
+487
View File
@@ -0,0 +1,487 @@
//! Generated by cot CLI 0.6.0 on 2026-04-29 10:36:47+00:00
#[derive(Debug, Copy, Clone)]
pub(super) struct Migration;
impl ::cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "web-petting";
const MIGRATION_NAME: &'static str = "m_0001_initial";
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[];
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
::cot::db::migrations::Operation::create_model()
.table_name(::cot::db::Identifier::new("web_petting__user"))
.fields(
&[
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("id"),
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
)
.auto()
.primary_key()
.set_null(
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("login"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
.unique(),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("password_hash"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("display_name"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("status"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("created_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("updated_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
),
],
)
.build(),
::cot::db::migrations::Operation::create_model()
.table_name(::cot::db::Identifier::new("web_petting__setting"))
.fields(
&[
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("id"),
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
)
.auto()
.primary_key()
.set_null(
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("key"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
.unique(),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("value"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("updated_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
),
],
)
.build(),
::cot::db::migrations::Operation::create_model()
.table_name(::cot::db::Identifier::new("web_petting__client"))
.fields(
&[
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("id"),
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
)
.auto()
.primary_key()
.set_null(
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("name"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("phone"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("email"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("address"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("notes"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("media_token"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
.unique(),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("status"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("created_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("updated_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
),
],
)
.build(),
::cot::db::migrations::Operation::create_model()
.table_name(::cot::db::Identifier::new("web_petting__visit"))
.fields(
&[
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("id"),
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
)
.auto()
.primary_key()
.set_null(
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("client_id"),
<cot::db::ForeignKey<
crate::models::Client,
> as ::cot::db::DatabaseField>::TYPE,
)
.foreign_key(
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
)
.set_null(
<cot::db::ForeignKey<
crate::models::Client,
> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("scheduled_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("duration_minutes"),
<Option<i32> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<Option<i32> as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("notes"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("status"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("created_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("updated_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
),
],
)
.build(),
::cot::db::migrations::Operation::create_model()
.table_name(::cot::db::Identifier::new("web_petting__media"))
.fields(
&[
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("id"),
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
)
.auto()
.primary_key()
.set_null(
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("client_id"),
<cot::db::ForeignKey<
crate::models::Client,
> as ::cot::db::DatabaseField>::TYPE,
)
.foreign_key(
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
)
.set_null(
<cot::db::ForeignKey<
crate::models::Client,
> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("visit_id"),
<Option<
cot::db::ForeignKey<crate::models::Visit>,
> as ::cot::db::DatabaseField>::TYPE,
)
.foreign_key(
<crate::models::Visit as ::cot::db::Model>::TABLE_NAME,
<crate::models::Visit as ::cot::db::Model>::PRIMARY_KEY_NAME,
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
)
.set_null(
<Option<
cot::db::ForeignKey<crate::models::Visit>,
> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("file_path"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("file_type"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("caption"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("status"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("created_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
),
],
)
.build(),
::cot::db::migrations::Operation::create_model()
.table_name(::cot::db::Identifier::new("web_petting__lead"))
.fields(
&[
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("id"),
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
)
.auto()
.primary_key()
.set_null(
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("name"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("phone"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("email"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("comment"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("status"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("client_id"),
<Option<
cot::db::ForeignKey<crate::models::Client>,
> as ::cot::db::DatabaseField>::TYPE,
)
.foreign_key(
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
)
.set_null(
<Option<
cot::db::ForeignKey<crate::models::Client>,
> as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("created_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("updated_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
)
.set_null(
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
),
],
)
.build(),
];
}
#[derive(::core::fmt::Debug)]
#[::cot::db::model(model_type = "migration")]
struct _Client {
#[model(primary_key)]
pub id: cot::db::Auto<i64>,
pub name: String,
pub phone: Option<String>,
pub email: Option<String>,
pub address: Option<String>,
pub notes: Option<String>,
/// Unique token for the public media page (client views photos/videos here).
#[model(unique)]
pub media_token: String,
/// active | archived
pub status: String,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
#[derive(::core::fmt::Debug)]
#[::cot::db::model(model_type = "migration")]
struct _Lead {
#[model(primary_key)]
pub id: cot::db::Auto<i64>,
pub name: String,
pub phone: Option<String>,
pub email: Option<String>,
pub comment: Option<String>,
/// new | in_progress | converted | rejected
pub status: String,
pub client_id: Option<cot::db::ForeignKey<crate::models::Client>>,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
#[derive(::core::fmt::Debug)]
#[::cot::db::model(model_type = "migration")]
struct _Media {
#[model(primary_key)]
pub id: cot::db::Auto<i64>,
pub client_id: cot::db::ForeignKey<crate::models::Client>,
pub visit_id: Option<cot::db::ForeignKey<crate::models::Visit>>,
pub file_path: String,
/// photo | video
pub file_type: String,
pub caption: Option<String>,
/// active | archived
pub status: String,
pub created_at: chrono::NaiveDateTime,
}
#[derive(::core::fmt::Debug)]
#[::cot::db::model(model_type = "migration")]
struct _Setting {
#[model(primary_key)]
pub id: cot::db::Auto<i64>,
#[model(unique)]
pub key: String,
pub value: String,
pub updated_at: chrono::NaiveDateTime,
}
#[derive(::core::fmt::Debug)]
#[::cot::db::model(model_type = "migration")]
struct _User {
#[model(primary_key)]
pub id: cot::db::Auto<i64>,
#[model(unique)]
pub login: String,
pub password_hash: String,
pub display_name: Option<String>,
/// active | archived
pub status: String,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
#[derive(::core::fmt::Debug)]
#[::cot::db::model(model_type = "migration")]
struct _Visit {
#[model(primary_key)]
pub id: cot::db::Auto<i64>,
pub client_id: cot::db::ForeignKey<crate::models::Client>,
pub scheduled_at: chrono::NaiveDateTime,
pub duration_minutes: Option<i32>,
pub notes: Option<String>,
/// scheduled | completed | cancelled
pub status: String,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
+88
View File
@@ -0,0 +1,88 @@
//! Migration: update Visit model for scheduling + add Client.color
//! Visit: Remove scheduled_at, duration_minutes; Add user_id, visit_date, time_start, time_end
//! Client: Add color
#[derive(Debug, Copy, Clone)]
pub(super) struct Migration;
impl ::cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "web-petting";
const MIGRATION_NAME: &'static str = "m_0002_visit_schedule";
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[
::cot::db::migrations::MigrationDependency::migration("web-petting", "m_0001_initial"),
];
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
// Add color to client (nullable for existing rows)
::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__client"))
.field(
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("color"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE)
)
.build(),
// Remove old visit fields
::cot::db::migrations::Operation::remove_field()
.table_name(::cot::db::Identifier::new("web_petting__visit"))
.field(::cot::db::migrations::Field::new(
::cot::db::Identifier::new("scheduled_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
))
.build(),
::cot::db::migrations::Operation::remove_field()
.table_name(::cot::db::Identifier::new("web_petting__visit"))
.field(::cot::db::migrations::Field::new(
::cot::db::Identifier::new("duration_minutes"),
<Option<i32> as ::cot::db::DatabaseField>::TYPE,
).set_null(<Option<i32> as ::cot::db::DatabaseField>::NULLABLE))
.build(),
// Add new fields
::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__visit"))
.field(
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("user_id"),
<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::TYPE,
)
.foreign_key(
<crate::models::User as ::cot::db::Model>::TABLE_NAME,
<crate::models::User as ::cot::db::Model>::PRIMARY_KEY_NAME,
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
)
.set_null(<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::NULLABLE)
)
.build(),
::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__visit"))
.field(
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("visit_date"),
<chrono::NaiveDate as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<chrono::NaiveDate as ::cot::db::DatabaseField>::NULLABLE)
)
.build(),
::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__visit"))
.field(
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("time_start"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
)
.build(),
::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__visit"))
.field(
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("time_end"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
)
.build(),
];
}
+23
View File
@@ -0,0 +1,23 @@
//! Migration: add client_feedback to Visit
#[derive(Debug, Copy, Clone)]
pub(super) struct Migration;
impl ::cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "web-petting";
const MIGRATION_NAME: &'static str = "m_0003_visit_feedback";
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[
::cot::db::migrations::MigrationDependency::migration("web-petting", "m_0002_visit_schedule"),
];
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__visit"))
.field(
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("client_feedback"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE)
)
.build(),
];
}
@@ -0,0 +1,23 @@
//! Migration: add public_notes to Visit
#[derive(Debug, Copy, Clone)]
pub(super) struct Migration;
impl ::cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "web-petting";
const MIGRATION_NAME: &'static str = "m_0004_visit_public_notes";
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[
::cot::db::migrations::MigrationDependency::migration("web-petting", "m_0003_visit_feedback"),
];
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__visit"))
.field(
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("public_notes"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE)
)
.build(),
];
}
+198
View File
@@ -0,0 +1,198 @@
use cot::db::{model, Auto, ForeignKey};
/// Lead status: new request from the website
/// new -> in_progress -> converted | rejected
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LeadStatus {
New,
InProgress,
Converted,
Rejected,
}
impl LeadStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::New => "new",
Self::InProgress => "in_progress",
Self::Converted => "converted",
Self::Rejected => "rejected",
}
}
}
/// Client status
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientStatus {
Active,
Archived,
}
impl ClientStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Archived => "archived",
}
}
}
/// Visit status
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VisitStatus {
Scheduled,
Completed,
Cancelled,
}
impl VisitStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Scheduled => "scheduled",
Self::Completed => "completed",
Self::Cancelled => "cancelled",
}
}
}
/// Media file type
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MediaType {
Photo,
Video,
}
impl MediaType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Photo => "photo",
Self::Video => "video",
}
}
}
/// User (admin) status
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UserStatus {
Active,
Archived,
}
impl UserStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Archived => "archived",
}
}
}
// ---------------------------------------------------------------------------
// Models
// ---------------------------------------------------------------------------
/// A lead submitted via the public website form.
#[derive(Debug, Clone)]
#[model]
pub struct Lead {
#[model(primary_key)]
pub id: Auto<i64>,
pub name: String,
pub phone: Option<String>,
pub email: Option<String>,
pub comment: Option<String>,
/// new | in_progress | converted | rejected
pub status: String,
pub client_id: Option<ForeignKey<Client>>,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
/// A confirmed client created from a lead (or manually).
#[derive(Debug, Clone)]
#[model]
pub struct Client {
#[model(primary_key)]
pub id: Auto<i64>,
pub name: String,
pub phone: Option<String>,
pub email: Option<String>,
pub address: Option<String>,
pub notes: Option<String>,
/// Unique token for the public media page (client views photos/videos here).
#[model(unique)]
pub media_token: String,
/// Hex color for calendar display, e.g. "#7c6ed4"
pub color: Option<String>,
/// active | archived
pub status: String,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
/// A scheduled pet-sitting visit.
#[derive(Debug, Clone)]
#[model]
pub struct Visit {
#[model(primary_key)]
pub id: Auto<i64>,
pub client_id: ForeignKey<Client>,
pub user_id: ForeignKey<User>,
pub visit_date: chrono::NaiveDate,
pub time_start: String,
pub time_end: String,
pub notes: Option<String>,
/// Public notes visible to client on their portal.
pub public_notes: Option<String>,
/// Feedback text from client via portal.
pub client_feedback: Option<String>,
/// scheduled | completed | cancelled
pub status: String,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
/// A photo or video uploaded for a client (optionally tied to a visit).
#[derive(Debug, Clone)]
#[model]
pub struct Media {
#[model(primary_key)]
pub id: Auto<i64>,
pub client_id: ForeignKey<Client>,
pub visit_id: Option<ForeignKey<Visit>>,
pub file_path: String,
/// photo | video
pub file_type: String,
pub caption: Option<String>,
/// active | archived
pub status: String,
pub created_at: chrono::NaiveDateTime,
}
/// An admin user who can log in to manage the system.
#[derive(Debug, Clone)]
#[model]
pub struct User {
#[model(primary_key)]
pub id: Auto<i64>,
#[model(unique)]
pub login: String,
pub password_hash: String,
pub display_name: Option<String>,
/// active | archived
pub status: String,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
/// Global key-value settings (telegram_bot_token, telegram_chat_id, etc.).
#[derive(Debug, Clone)]
#[model]
pub struct Setting {
#[model(primary_key)]
pub id: Auto<i64>,
#[model(unique)]
pub key: String,
pub value: String,
pub updated_at: chrono::NaiveDateTime,
}
+322
View File
@@ -0,0 +1,322 @@
use cot::db::{Auto, Database, Model};
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::Template;
use serde::Deserialize;
use cot::db::query;
use crate::i18n::{Lang, Translations};
use crate::models::{Client, Lead, Media, Setting, User, Visit};
use crate::telegram;
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::Ru)
}
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()
}
fn now() -> chrono::NaiveDateTime {
chrono::Utc::now().naive_utc()
}
#[derive(Debug, Template)]
#[template(path = "landing.html")]
struct LandingTemplate<'a> {
t: &'a Translations,
lang: Lang,
contact_info: String,
}
#[derive(Debug, Template)]
#[template(path = "thank_you.html")]
struct ThankYouTemplate<'a> {
t: &'a Translations,
lang: Lang,
}
async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let key = "contact_info".to_string();
let contact_info = query!(Setting, $key == key)
.get(&db)
.await?
.map(|s| s.value)
.unwrap_or_default();
let body = LandingTemplate { t: lang.t(), lang, contact_info }.render()?;
html_response(body, lang)
}
#[derive(Deserialize)]
struct LeadForm {
name: String,
phone: Option<String>,
comment: Option<String>,
}
async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let body = request.into_body();
let bytes = body.into_bytes().await?;
let form: LeadForm =
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?;
let mut lead = Lead {
id: Auto::auto(),
name: form.name,
phone: form.phone.filter(|s| !s.trim().is_empty()),
email: None,
comment: form.comment.filter(|s| !s.trim().is_empty()),
status: "new".to_string(),
client_id: None,
created_at: now(),
updated_at: now(),
};
lead.save(&db).await?;
telegram::notify_new_lead(
&db,
&lead.name,
lead.phone.as_deref(),
lead.comment.as_deref(),
)
.await;
let rendered = ThankYouTemplate { t: lang.t(), lang }.render()?;
html_response(rendered, lang)
}
// ---------------------------------------------------------------------------
// Client Portal
// ---------------------------------------------------------------------------
#[derive(Debug)]
struct PortalVisit {
visit: Visit,
admin_name: String,
media: Vec<Media>,
}
#[derive(Debug, Template)]
#[template(path = "client_portal.html")]
struct ClientPortalTemplate<'a> {
t: &'a Translations,
lang: Lang,
client: Client,
upcoming: Vec<PortalVisit>,
past: Vec<PortalVisit>,
feedback_sent: bool,
}
async fn client_portal(
request: Request,
db: Database,
Path(token): Path<String>,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
let feedback_sent = request
.uri()
.query()
.map(|q| q.split('&').any(|p| p == "feedback=ok"))
.unwrap_or(false);
let client = match query!(Client, $media_token == token).get(&db).await? {
Some(c) => c,
None => return Html::new("404").into_response(),
};
let client_id = client.id.unwrap();
let today = chrono::Utc::now().date_naive();
let mut visits = Visit::objects().all(&db).await?;
visits.retain(|v| {
v.client_id.primary_key().unwrap() == client_id && v.status != "cancelled"
});
visits.sort_by(|a, b| a.visit_date.cmp(&b.visit_date).then(a.time_start.cmp(&b.time_start)));
let users = User::objects().all(&db).await?;
let all_media = Media::objects().all(&db).await?;
let build_portal_visit = |v: Visit| -> PortalVisit {
let uid: i64 = v.user_id.primary_key().unwrap();
let admin_name = users
.iter()
.find(|u| u.id.unwrap() == uid)
.map(|u| u.display_name.as_deref().unwrap_or(&u.login).to_string())
.unwrap_or_default();
let vid = v.id.unwrap();
let media: Vec<Media> = all_media
.iter()
.filter(|m| {
m.status == "active"
&& m.visit_id
.as_ref()
.map(|fk| fk.primary_key().unwrap() == vid)
.unwrap_or(false)
})
.cloned()
.collect();
PortalVisit { visit: v, admin_name, media }
};
let mut upcoming = Vec::new();
let mut past = Vec::new();
for v in visits {
if v.visit_date >= today && v.status == "scheduled" {
upcoming.push(build_portal_visit(v));
} else {
past.push(build_portal_visit(v));
}
}
past.reverse(); // newest first
let body = ClientPortalTemplate {
t: lang.t(),
lang,
client,
upcoming,
past,
feedback_sent,
}
.render()?;
html_response(body, lang)
}
#[derive(Deserialize)]
struct FeedbackForm {
feedback: String,
}
async fn submit_feedback(
request: Request,
db: Database,
Path((token, visit_id)): Path<(String, i64)>,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
// Verify token matches visit's client
let token_clone = token.clone();
let client = match query!(Client, $media_token == token).get(&db).await? {
Some(c) => c,
None => return Html::new("404").into_response(),
};
let client_id = client.id.unwrap();
let bytes = request.into_body().into_bytes().await?;
let form: FeedbackForm =
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?;
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
if visit.client_id.primary_key().unwrap() == client_id {
visit.client_feedback = Some(form.feedback);
visit.updated_at = now();
visit.save(&db).await?;
}
}
Redirect::new(format!("/client/{}?lang={}&feedback=ok", token_clone, lang.code())).into_response()
}
/// Serve media files for the client portal (no auth required, but only via token).
async fn portal_media(
_request: Request,
db: Database,
Path((token, media_id)): Path<(String, i64)>,
) -> cot::Result<Response> {
// Verify token
let client = match query!(Client, $media_token == token).get(&db).await? {
Some(c) => c,
None => return Html::new("404").into_response(),
};
let client_id = client.id.unwrap();
let media = match query!(Media, $id == media_id).get(&db).await? {
Some(m) if m.client_id.primary_key().unwrap() == client_id && m.status == "active" => m,
_ => 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(),
}
}
pub fn public_router() -> Router {
Router::with_urls([
Route::with_handler_and_name("/", landing_page, "landing"),
Route::with_handler_and_name("/submit", submit_lead, "submit-lead"),
Route::with_handler_and_name("/client/{token}", client_portal, "client-portal"),
Route::with_handler_and_name(
"/client/{token}/{visit_id}/feedback",
submit_feedback,
"client-feedback",
),
Route::with_handler_and_name(
"/client/{token}/media/{media_id}",
portal_media,
"client-media",
),
])
}
+44
View File
@@ -0,0 +1,44 @@
use cot::db::{Database, query};
use crate::models::Setting;
/// Send a Telegram message using bot settings from DB.
/// Silently ignores errors (missing config, network issues) — notifications are best-effort.
pub async fn notify_new_lead(db: &Database, name: &str, phone: Option<&str>, comment: Option<&str>) {
let token = match get_setting(db, "telegram_bot_token").await {
Some(t) if !t.is_empty() => t,
_ => return,
};
let chat_id = match get_setting(db, "telegram_chat_id").await {
Some(c) if !c.is_empty() => c,
_ => return,
};
let mut text = format!("📋 Новая заявка!\n\nИмя: {name}");
if let Some(phone) = phone.filter(|s| !s.is_empty()) {
text.push_str(&format!("\nТелефон: {phone}"));
}
if let Some(comment) = comment.filter(|s| !s.is_empty()) {
text.push_str(&format!("\nКомментарий: {comment}"));
}
let url = format!("https://api.telegram.org/bot{token}/sendMessage");
let _ = reqwest::Client::new()
.post(&url)
.json(&serde_json::json!({
"chat_id": chat_id,
"text": text,
}))
.send()
.await;
}
async fn get_setting(db: &Database, key_name: &str) -> Option<String> {
let k = key_name.to_string();
query!(Setting, $key == k)
.get(db)
.await
.ok()
.flatten()
.map(|s| s.value)
}