Added notifications, image preview generator
This commit is contained in:
+402
-7
@@ -1,5 +1,6 @@
|
||||
use chrono::Datelike;
|
||||
use cot::Template;
|
||||
use cot::db::{Auto, Database, Model};
|
||||
use cot::db::{Auto, Database, ForeignKey, Model};
|
||||
use cot::html::Html;
|
||||
use cot::request::Request;
|
||||
use cot::request::extractors::Path;
|
||||
@@ -11,7 +12,7 @@ use tracing::info;
|
||||
use cot::db::query;
|
||||
|
||||
use crate::i18n::{Lang, Translations};
|
||||
use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit};
|
||||
use crate::models::{Client, Lead, Media, PushSubscription, Setting, Testimonial, User, Visit};
|
||||
use crate::telegram;
|
||||
|
||||
fn detect_lang(request: &Request) -> Lang {
|
||||
@@ -215,6 +216,21 @@ struct PortalVisit {
|
||||
media: Vec<Media>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CalendarDay {
|
||||
number: u32,
|
||||
class_name: &'static str,
|
||||
href: Option<String>,
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CalendarMonth {
|
||||
label: String,
|
||||
leading_blanks: Vec<u8>,
|
||||
days: Vec<CalendarDay>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "client_portal.html")]
|
||||
struct ClientPortalTemplate<'a> {
|
||||
@@ -225,6 +241,62 @@ struct ClientPortalTemplate<'a> {
|
||||
past: Vec<PortalVisit>,
|
||||
feedback_sent: bool,
|
||||
turnstile_site_key: String,
|
||||
notifications_enabled: bool,
|
||||
vapid_public_key: String,
|
||||
calendar_months: Vec<CalendarMonth>,
|
||||
page: usize,
|
||||
total_pages: usize,
|
||||
has_previous_page: bool,
|
||||
has_next_page: bool,
|
||||
}
|
||||
|
||||
const PORTAL_VISITS_PER_PAGE: usize = 10;
|
||||
|
||||
fn query_page(request: &Request) -> usize {
|
||||
request
|
||||
.uri()
|
||||
.query()
|
||||
.and_then(|query| {
|
||||
query.split('&').find_map(|part| {
|
||||
part.strip_prefix("page=")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|page| *page > 0)
|
||||
})
|
||||
})
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
fn month_label(year: i32, month: u32, lang: Lang) -> String {
|
||||
const RU: [&str; 12] = [
|
||||
"Январь",
|
||||
"Февраль",
|
||||
"Март",
|
||||
"Апрель",
|
||||
"Май",
|
||||
"Июнь",
|
||||
"Июль",
|
||||
"Август",
|
||||
"Сентябрь",
|
||||
"Октябрь",
|
||||
"Ноябрь",
|
||||
"Декабрь",
|
||||
];
|
||||
const EN: [&str; 12] = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
let names = if lang == Lang::Ru { RU } else { EN };
|
||||
format!("{} {year}", names[(month - 1) as usize])
|
||||
}
|
||||
|
||||
async fn client_portal(
|
||||
@@ -238,6 +310,7 @@ async fn client_portal(
|
||||
.query()
|
||||
.map(|q| q.split('&').any(|p| p == "feedback=ok"))
|
||||
.unwrap_or(false);
|
||||
let requested_page = query_page(&request);
|
||||
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(c) if c.status != "deleted" => c,
|
||||
@@ -291,16 +364,108 @@ async fn client_portal(
|
||||
}
|
||||
};
|
||||
|
||||
let mut upcoming = Vec::new();
|
||||
let mut past = Vec::new();
|
||||
let mut upcoming_visits = Vec::new();
|
||||
let mut past_visits = Vec::new();
|
||||
for v in visits {
|
||||
if v.visit_date >= today && v.status == "scheduled" {
|
||||
upcoming.push(build_portal_visit(v));
|
||||
upcoming_visits.push(v);
|
||||
} else {
|
||||
past.push(build_portal_visit(v));
|
||||
past_visits.push(v);
|
||||
}
|
||||
}
|
||||
past.reverse(); // newest first
|
||||
past_visits.reverse(); // newest first
|
||||
|
||||
let total_pages = past_visits.len().div_ceil(PORTAL_VISITS_PER_PAGE).max(1);
|
||||
let page = requested_page.min(total_pages);
|
||||
let page_start = (page - 1) * PORTAL_VISITS_PER_PAGE;
|
||||
let page_end = (page_start + PORTAL_VISITS_PER_PAGE).min(past_visits.len());
|
||||
let past = past_visits[page_start..page_end]
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(&build_portal_visit)
|
||||
.collect();
|
||||
let upcoming: Vec<_> = upcoming_visits
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(&build_portal_visit)
|
||||
.collect();
|
||||
|
||||
let mut month_keys: Vec<(i32, u32)> = past_visits
|
||||
.iter()
|
||||
.chain(upcoming_visits.iter())
|
||||
.map(|visit| (visit.visit_date.year(), visit.visit_date.month()))
|
||||
.collect();
|
||||
month_keys.sort();
|
||||
month_keys.dedup();
|
||||
month_keys.reverse();
|
||||
let calendar_months = month_keys
|
||||
.into_iter()
|
||||
.map(|(year, month)| {
|
||||
let first = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
|
||||
let next_month = if month == 12 {
|
||||
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
|
||||
} else {
|
||||
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
|
||||
};
|
||||
let days_in_month = (next_month - first).num_days() as u32;
|
||||
let leading_blanks = vec![0; first.weekday().num_days_from_monday() as usize];
|
||||
let days = (1..=days_in_month)
|
||||
.map(|day| {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(year, month, day).unwrap();
|
||||
if let Some(_visit) = upcoming_visits
|
||||
.iter()
|
||||
.find(|visit| visit.visit_date == date)
|
||||
{
|
||||
CalendarDay {
|
||||
number: day,
|
||||
class_name: "future",
|
||||
href: None,
|
||||
title: lang.t().portal_future_visit.to_string(),
|
||||
}
|
||||
} else if let Some((index, visit)) = past_visits
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, visit)| visit.visit_date == date)
|
||||
{
|
||||
CalendarDay {
|
||||
number: day,
|
||||
class_name: "past",
|
||||
href: Some(format!(
|
||||
"?page={}#visit-{}",
|
||||
index / PORTAL_VISITS_PER_PAGE + 1,
|
||||
visit.id.unwrap()
|
||||
)),
|
||||
title: lang.t().visit_status(&visit.status).to_string(),
|
||||
}
|
||||
} else {
|
||||
CalendarDay {
|
||||
number: day,
|
||||
class_name: "empty",
|
||||
href: None,
|
||||
title: String::new(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
CalendarMonth {
|
||||
label: month_label(year, month, lang),
|
||||
leading_blanks,
|
||||
days,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let notification_key = "client_notifications_enabled".to_string();
|
||||
let vapid_public_key = crate::web_push::load_config(&db)
|
||||
.await
|
||||
.map(|config| config.public_key)
|
||||
.unwrap_or_default();
|
||||
let notifications_enabled = !vapid_public_key.is_empty()
|
||||
&& query!(Setting, $key == notification_key)
|
||||
.get(&db)
|
||||
.await?
|
||||
.map(|setting| setting.value == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
||||
let body = ClientPortalTemplate {
|
||||
@@ -311,11 +476,170 @@ async fn client_portal(
|
||||
past,
|
||||
feedback_sent,
|
||||
turnstile_site_key,
|
||||
notifications_enabled,
|
||||
vapid_public_key,
|
||||
calendar_months,
|
||||
page,
|
||||
total_pages,
|
||||
has_previous_page: page > 1,
|
||||
has_next_page: page < total_pages,
|
||||
}
|
||||
.render()?;
|
||||
html_response(body, lang)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PushKeysForm {
|
||||
p256dh: String,
|
||||
auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PushSubscriptionForm {
|
||||
endpoint: String,
|
||||
keys: PushKeysForm,
|
||||
#[serde(default)]
|
||||
language: String,
|
||||
}
|
||||
|
||||
async fn portal_push_subscribe(
|
||||
request: Request,
|
||||
db: Database,
|
||||
Path(token): Path<String>,
|
||||
) -> cot::Result<Response> {
|
||||
if crate::web_push::load_config(&db).await.is_none() {
|
||||
return Html::new("404").into_response();
|
||||
}
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(client) if client.status != "deleted" => client,
|
||||
_ => return Html::new("404").into_response(),
|
||||
};
|
||||
let bytes = request.into_body().into_bytes().await?;
|
||||
let form: PushSubscriptionForm =
|
||||
serde_json::from_slice(&bytes).map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||
if !form.endpoint.starts_with("https://")
|
||||
|| form.endpoint.len() > 4096
|
||||
|| form.keys.p256dh.len() > 512
|
||||
|| form.keys.auth.len() > 256
|
||||
{
|
||||
return Html::new("400").into_response();
|
||||
}
|
||||
let endpoint = form.endpoint.clone();
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint)
|
||||
.get(&db)
|
||||
.await?
|
||||
{
|
||||
subscription.client_id = ForeignKey::PrimaryKey(Auto::fixed(client.id.unwrap()));
|
||||
subscription.p256dh = form.keys.p256dh;
|
||||
subscription.auth = form.keys.auth;
|
||||
subscription.language = if form.language == "ru" { "ru" } else { "en" }.to_string();
|
||||
subscription.status = "active".to_string();
|
||||
subscription.updated_at = now;
|
||||
subscription.save(&db).await?;
|
||||
} else {
|
||||
let mut subscription = PushSubscription {
|
||||
id: Auto::auto(),
|
||||
client_id: ForeignKey::PrimaryKey(Auto::fixed(client.id.unwrap())),
|
||||
endpoint: form.endpoint,
|
||||
p256dh: form.keys.p256dh,
|
||||
auth: form.keys.auth,
|
||||
language: if form.language == "ru" { "ru" } else { "en" }.to_string(),
|
||||
status: "active".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
subscription.save(&db).await?;
|
||||
}
|
||||
let mut response = Response::new(cot::Body::fixed("{\"ok\":true}"));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", "application/json".parse().unwrap());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn portal_push_unsubscribe(
|
||||
request: Request,
|
||||
db: Database,
|
||||
Path(token): Path<String>,
|
||||
) -> cot::Result<Response> {
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(client) if client.status != "deleted" => client,
|
||||
_ => return Html::new("404").into_response(),
|
||||
};
|
||||
let bytes = request.into_body().into_bytes().await?;
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||
let endpoint = value
|
||||
.get("endpoint")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint)
|
||||
.get(&db)
|
||||
.await?
|
||||
{
|
||||
if subscription.client_id.primary_key().unwrap() == client.id.unwrap() {
|
||||
subscription.status = "archived".to_string();
|
||||
subscription.updated_at = chrono::Utc::now().naive_utc();
|
||||
subscription.save(&db).await?;
|
||||
}
|
||||
}
|
||||
let mut response = Response::new(cot::Body::fixed("{\"ok\":true}"));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", "application/json".parse().unwrap());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn web_manifest(_request: Request, Path(token): Path<String>) -> cot::Result<Response> {
|
||||
let manifest = serde_json::json!({
|
||||
"id": format!("/client/{token}"),
|
||||
"name": "Pet Sitting Visits",
|
||||
"short_name": "Pet Visits",
|
||||
"start_url": format!("/client/{token}"),
|
||||
"display": "standalone",
|
||||
"background_color": "#f8f7ff",
|
||||
"theme_color": "#7c6cff",
|
||||
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
|
||||
});
|
||||
let mut response = Response::new(cot::Body::fixed(manifest.to_string()));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", "application/manifest+json".parse().unwrap());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn service_worker(_request: Request) -> cot::Result<Response> {
|
||||
let script = r#"
|
||||
self.addEventListener('push', function(event) {
|
||||
var data = event.data ? event.data.json() : {};
|
||||
event.waitUntil(self.registration.showNotification(data.title || 'Pet Visits', {
|
||||
body: data.body || '', tag: data.tag || 'visit', data: { url: data.url || '/' },
|
||||
icon: '/favicon.svg', badge: '/favicon.svg'
|
||||
}));
|
||||
});
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
event.notification.close();
|
||||
var target = new URL(event.notification.data.url || '/', self.location.origin).href;
|
||||
event.waitUntil(clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(list) {
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if ('focus' in list[i]) { list[i].navigate(target); return list[i].focus(); }
|
||||
}
|
||||
return clients.openWindow ? clients.openWindow(target) : undefined;
|
||||
}));
|
||||
});
|
||||
"#;
|
||||
let mut response = Response::new(cot::Body::fixed(script));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", "application/javascript".parse().unwrap());
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("service-worker-allowed", "/".parse().unwrap());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FeedbackForm {
|
||||
feedback: String,
|
||||
@@ -430,6 +754,56 @@ async fn portal_media(
|
||||
}
|
||||
}
|
||||
|
||||
async fn portal_media_thumbnail(
|
||||
_request: Request,
|
||||
db: Database,
|
||||
Path((token, media_id)): Path<(String, i64)>,
|
||||
) -> cot::Result<Response> {
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(client) if client.status != "deleted" => client,
|
||||
_ => return Html::new("404").into_response(),
|
||||
};
|
||||
let media = match query!(Media, $id == media_id).get(&db).await? {
|
||||
Some(media)
|
||||
if media.client_id.primary_key().unwrap() == client.id.unwrap()
|
||||
&& media.status == "active"
|
||||
&& media.file_type == "photo" =>
|
||||
{
|
||||
media
|
||||
}
|
||||
_ => return Html::new("404").into_response(),
|
||||
};
|
||||
if let Some(visit) = &media.visit_id {
|
||||
let visit_id = visit.primary_key().unwrap();
|
||||
match query!(Visit, $id == visit_id).get(&db).await? {
|
||||
Some(visit) if visit.status != "deleted" => {}
|
||||
_ => return Html::new("404").into_response(),
|
||||
}
|
||||
}
|
||||
match crate::uploads::ensure_thumbnail(&media.file_path).await {
|
||||
Ok(path) => {
|
||||
let mut response = crate::uploads::ranged_file_response(&path, "image/jpeg", None)
|
||||
.await
|
||||
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
"private, max-age=31536000, immutable".parse().unwrap(),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(media_id, %error, "failed to create portal media thumbnail");
|
||||
crate::uploads::ranged_file_response(
|
||||
&media.file_path,
|
||||
crate::uploads::content_type_for_path(&media.file_path),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| cot::Error::internal(error.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_testimonial_image(
|
||||
_request: Request,
|
||||
db: Database,
|
||||
@@ -578,6 +952,12 @@ pub fn public_router() -> Router {
|
||||
Router::with_urls([
|
||||
Route::with_handler_and_name("/", landing_page, "landing"),
|
||||
Route::with_handler_and_name("/favicon.svg", favicon, "favicon"),
|
||||
Route::with_handler_and_name(
|
||||
"/client/{token}/manifest.webmanifest",
|
||||
web_manifest,
|
||||
"web-manifest",
|
||||
),
|
||||
Route::with_handler_and_name("/service-worker.js", service_worker, "service-worker"),
|
||||
Route::with_handler_and_name("/static/{filename}", serve_static, "static-file"),
|
||||
Route::with_handler_and_name("/robots.txt", robots_txt, "robots-txt"),
|
||||
Route::with_handler_and_name("/sitemap.xml", sitemap_xml, "sitemap-xml"),
|
||||
@@ -588,6 +968,16 @@ pub fn public_router() -> Router {
|
||||
"testimonial-image",
|
||||
),
|
||||
Route::with_handler_and_name("/client/{token}", client_portal, "client-portal"),
|
||||
Route::with_handler_and_name(
|
||||
"/client/{token}/push/subscribe",
|
||||
portal_push_subscribe,
|
||||
"client-push-subscribe",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/client/{token}/push/unsubscribe",
|
||||
portal_push_unsubscribe,
|
||||
"client-push-unsubscribe",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/client/{token}/{visit_id}/feedback",
|
||||
submit_feedback,
|
||||
@@ -598,5 +988,10 @@ pub fn public_router() -> Router {
|
||||
portal_media,
|
||||
"client-media",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/client/{token}/media/{media_id}/thumbnail",
|
||||
portal_media_thumbnail,
|
||||
"client-media-thumbnail",
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user