From 45b9136c690b9228b0c69ba2f293fa415eff5246 Mon Sep 17 00:00:00 2001 From: Syfaro Date: Thu, 5 Sep 2024 12:06:15 -0400 Subject: [PATCH] Add site alerts. --- frontend/src/ts/utilities.ts | 26 ++++ .../20240905145854_site_alerts.down.sql | 1 + migrations/20240905145854_site_alerts.up.sql | 15 +++ queries/site_alerts/active_for_user.sql | 13 ++ queries/site_alerts/create.sql | 4 + queries/site_alerts/deactivate.sql | 6 + queries/site_alerts/dismiss.sql | 4 + queries/site_alerts/list.sql | 11 ++ sqlx-data.json | 119 ++++++++++++++++++ src/admin.rs | 77 +++++++++++- src/api.rs | 18 ++- src/models.rs | 46 +++++++ src/user.rs | 7 +- templates/admin/_sidebar.html | 1 + templates/admin/alerts.html | 65 ++++++++++ templates/user/index.html | 14 +++ 16 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 migrations/20240905145854_site_alerts.down.sql create mode 100644 migrations/20240905145854_site_alerts.up.sql create mode 100644 queries/site_alerts/active_for_user.sql create mode 100644 queries/site_alerts/create.sql create mode 100644 queries/site_alerts/deactivate.sql create mode 100644 queries/site_alerts/dismiss.sql create mode 100644 queries/site_alerts/list.sql create mode 100644 templates/admin/alerts.html diff --git a/frontend/src/ts/utilities.ts b/frontend/src/ts/utilities.ts index 597d126..5ea994b 100644 --- a/frontend/src/ts/utilities.ts +++ b/frontend/src/ts/utilities.ts @@ -232,3 +232,29 @@ document.querySelectorAll(".human-number").forEach((elem) => { const value = parseInt(elem.textContent?.trim() || "0", 10); elem.textContent = numberFormatter.format(value); }); + +document.querySelectorAll(".site-alert-delete").forEach((elem) => { + const alertId = (elem as HTMLElement).dataset.alertId; + if (!alertId) return; + + elem.addEventListener("click", async () => { + const formData = new URLSearchParams(); + formData.set("alert_id", alertId); + + const resp = await fetch("/api/alert/dismiss", { + method: "POST", + body: formData, + credentials: "same-origin", + }); + + if (resp.status !== 204) { + alert("Error dismissing alert, try again later."); + return; + } + + const notification = elem.closest(".notification"); + if (notification) { + notification.parentNode?.removeChild(notification); + } + }); +}); diff --git a/migrations/20240905145854_site_alerts.down.sql b/migrations/20240905145854_site_alerts.down.sql new file mode 100644 index 0000000..f4180d5 --- /dev/null +++ b/migrations/20240905145854_site_alerts.down.sql @@ -0,0 +1 @@ +DROP TABLE site_alert_dismiss, site_alert; diff --git a/migrations/20240905145854_site_alerts.up.sql b/migrations/20240905145854_site_alerts.up.sql new file mode 100644 index 0000000..47190b1 --- /dev/null +++ b/migrations/20240905145854_site_alerts.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE site_alert ( + id uuid PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT current_timestamp, + active boolean NOT NULL DEFAULT true, + content text NOT NULL +); + +CREATE INDEX site_alert_lookup_idx ON site_alert (created_at DESC); + +CREATE TABLE site_alert_dismiss ( + site_alert_id uuid NOT NULL REFERENCES site_alert (id) ON DELETE CASCADE, + user_account_id uuid NOT NULL REFERENCES user_account (id) ON DELETE CASCADE, + dismissed_at timestamp with time zone NOT NULL DEFAULT current_timestamp, + PRIMARY KEY (site_alert_id, user_account_id) +); diff --git a/queries/site_alerts/active_for_user.sql b/queries/site_alerts/active_for_user.sql new file mode 100644 index 0000000..9c0609d --- /dev/null +++ b/queries/site_alerts/active_for_user.sql @@ -0,0 +1,13 @@ +SELECT + id, + created_at, + active, + content +FROM + site_alert + LEFT JOIN site_alert_dismiss ON site_alert_dismiss.site_alert_id = site_alert.id AND site_alert_dismiss.user_account_id = $1 +WHERE + site_alert_dismiss.site_alert_id IS NULL + AND active = true +ORDER BY + created_at DESC; diff --git a/queries/site_alerts/create.sql b/queries/site_alerts/create.sql new file mode 100644 index 0000000..2e47eb9 --- /dev/null +++ b/queries/site_alerts/create.sql @@ -0,0 +1,4 @@ +INSERT INTO + site_alert (content) +VALUES + ($1) RETURNING id; diff --git a/queries/site_alerts/deactivate.sql b/queries/site_alerts/deactivate.sql new file mode 100644 index 0000000..ffee4af --- /dev/null +++ b/queries/site_alerts/deactivate.sql @@ -0,0 +1,6 @@ +UPDATE + site_alert +SET + active = false +WHERE + id = $1; diff --git a/queries/site_alerts/dismiss.sql b/queries/site_alerts/dismiss.sql new file mode 100644 index 0000000..061797a --- /dev/null +++ b/queries/site_alerts/dismiss.sql @@ -0,0 +1,4 @@ +INSERT INTO + site_alert_dismiss (site_alert_id, user_account_id) +VALUES + ($1, $2) ON CONFLICT DO NOTHING; diff --git a/queries/site_alerts/list.sql b/queries/site_alerts/list.sql new file mode 100644 index 0000000..73123fc --- /dev/null +++ b/queries/site_alerts/list.sql @@ -0,0 +1,11 @@ +SELECT + id, + created_at, + active, + content +FROM + site_alert +ORDER BY + created_at DESC +LIMIT + 50; diff --git a/sqlx-data.json b/sqlx-data.json index 62e2271..f6e8d2e 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -702,6 +702,55 @@ }, "query": "UPDATE\n reddit_subreddit\nSET\n disabled = $2\nWHERE\n name = $1;\n" }, + "2474795278440697bfcc6715d6991ada805326b49c8990ced49cb2e2f6640d13": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + } + }, + "query": "INSERT INTO\n site_alert_dismiss (site_alert_id, user_account_id)\nVALUES\n ($1, $2) ON CONFLICT DO NOTHING;\n" + }, + "27b076d619fbb181aec8f25683aa93a879cf44b1a420e140d1132c974afefd22": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Uuid" + }, + { + "name": "created_at", + "ordinal": 1, + "type_info": "Timestamptz" + }, + { + "name": "active", + "ordinal": 2, + "type_info": "Bool" + }, + { + "name": "content", + "ordinal": 3, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT\n id,\n created_at,\n active,\n content\nFROM\n site_alert\nORDER BY\n created_at DESC\nLIMIT\n 50;\n" + }, "2aa0ada2357ae99086950c0e5d8dfd750f977e830a031f24f62063c6277933f9": { "describe": { "columns": [ @@ -3455,6 +3504,18 @@ }, "query": "SELECT\n fullname \"fullname!\",\n subreddit_name \"subreddit_name!\",\n posted_at \"posted_at!\",\n author \"author!\",\n permalink \"permalink!\",\n content_link \"content_link!\",\n id \"id!\",\n post_fullname \"post_fullname!\",\n size \"size!\",\n sha256 \"sha256!\",\n perceptual_hash\nFROM\n reddit_image\n LEFT JOIN reddit_post ON reddit_image.post_fullname = reddit_post.fullname\nWHERE\n perceptual_hash <@ ($1, $2)\n" }, + "c9d3722f46f68561549ff425b6847b83fcc15f2be9dcc4d9d0820234970a5c8f": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Uuid" + ] + } + }, + "query": "UPDATE\n site_alert\nSET\n active = false\nWHERE\n id = $1;\n" + }, "cb8dc8dd0529619ea5a22e57af00b3f238e7b7429da8b405217e667c01740f86": { "describe": { "columns": [ @@ -3665,6 +3726,26 @@ }, "query": "UPDATE\n owned_media_item_account\nSET\n owned_media_item_id = $2\nWHERE\n owned_media_item_id = $1;\n" }, + "dc704be491f1b9c8dbe0c31defb86e59573ca01b99d3edcc8a253d3367ab9be3": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Uuid" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "INSERT INTO\n site_alert (content)\nVALUES\n ($1) RETURNING id;\n" + }, "ddd2f183cb5f6d8da4ad7fc5cdb056427bb0dce4b177d14902e5a75f6e04a82f": { "describe": { "columns": [ @@ -4101,6 +4182,44 @@ }, "query": "UPDATE\n flist_import_run\nSET\n finished_at = current_timestamp,\n max_id = $2\nWHERE\n id = $1;\n" }, + "ee254af35e9318df4f78bb5377968502439d4b3b2e41ff6f9722d7d8b01274ba": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Uuid" + }, + { + "name": "created_at", + "ordinal": 1, + "type_info": "Timestamptz" + }, + { + "name": "active", + "ordinal": 2, + "type_info": "Bool" + }, + { + "name": "content", + "ordinal": 3, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Uuid" + ] + } + }, + "query": "SELECT\n id,\n created_at,\n active,\n content\nFROM\n site_alert\n LEFT JOIN site_alert_dismiss ON site_alert_dismiss.site_alert_id = site_alert.id AND site_alert_dismiss.user_account_id = $1\nWHERE\n site_alert_dismiss.site_alert_id IS NULL\n AND active = true\nORDER BY\n created_at DESC;\n" + }, "f34303d1fa62539f48e60fb54a5b666ebf42852a75ce8251c6e4f90894cb46cf": { "describe": { "columns": [ diff --git a/src/admin.rs b/src/admin.rs index dd2988f..76eb8b9 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -8,6 +8,7 @@ use futures::TryFutureExt; use rand::Rng; use serde::Deserialize; use sha2::{Digest, Sha256}; +use uuid::Uuid; use crate::{ jobs::{JobInitiator, JobInitiatorExt, NewSubmissionJob}, @@ -21,11 +22,13 @@ pub fn service() -> Scope { admin_imports, admin_sites_reddit, admin_sites_flist, + admin_alerts, inject_post, job_manual, subreddit_add, subreddit_state, - flist_abort + flist_abort, + services![alert_create, alert_deactivate] ]) } @@ -460,3 +463,75 @@ async fn job_manual( .insert_header(("Location", request.url_for_static("admin_inject")?.as_str())) .finish()) } + +#[derive(Template)] +#[template(path = "admin/alerts.html")] +struct AdminAlerts { + alerts: Vec, +} + +#[get("/alerts", name = "admin_alerts")] +async fn admin_alerts( + conn: web::Data, + request: actix_web::HttpRequest, + user: models::User, +) -> Result { + if !user.is_admin { + return Err(actix_web::error::ErrorUnauthorized("Unauthorized").into()); + } + + let alerts = models::SiteAlert::list(&conn).await?; + + let body = AdminAlerts { alerts } + .wrap_admin(&request, &user) + .await + .render()?; + + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[derive(Deserialize)] +struct AlertCreateForm { + content: String, +} + +#[post("/alerts/create")] +async fn alert_create( + conn: web::Data, + request: actix_web::HttpRequest, + user: models::User, + web::Form(form): web::Form, +) -> Result { + if !user.is_admin { + return Err(actix_web::error::ErrorUnauthorized("Unauthorized").into()); + } + + models::SiteAlert::create(&conn, form.content).await?; + + Ok(HttpResponse::Found() + .insert_header(("Location", request.url_for_static("admin_alerts")?.as_str())) + .finish()) +} + +#[derive(Deserialize)] +struct AlertActionForm { + alert_id: Uuid, +} + +#[post("/alerts/deactivate")] +async fn alert_deactivate( + conn: web::Data, + request: actix_web::HttpRequest, + user: models::User, + web::Form(form): web::Form, +) -> Result { + if !user.is_admin { + return Err(actix_web::error::ErrorUnauthorized("Unauthorized").into()); + } + + models::SiteAlert::deactivate(&conn, form.alert_id).await?; + + Ok(HttpResponse::Found() + .insert_header(("Location", request.url_for_static("admin_alerts")?.as_str())) + .finish()) +} diff --git a/src/api.rs b/src/api.rs index 1709e63..15d00ea 100644 --- a/src/api.rs +++ b/src/api.rs @@ -280,6 +280,22 @@ async fn events( } } +#[derive(Deserialize)] +struct AlertDismissForm { + alert_id: Uuid, +} + +#[post("/alert/dismiss")] +async fn alert_dismiss( + user: models::User, + conn: web::Data, + web::Form(form): web::Form, +) -> Result { + models::SiteAlert::dismiss(&conn, form.alert_id, user.id).await?; + + Ok(HttpResponse::new(actix_http::StatusCode::NO_CONTENT)) +} + #[get("/ingest/stats")] async fn ingest_stats( user: models::User, @@ -483,7 +499,7 @@ async fn flist_lookup( pub fn service() -> Scope { web::scope("/api") - .service(services![events, ingest_stats, upload, flist_lookup]) + .service(services![events, alert_dismiss, ingest_stats, upload, flist_lookup]) .service(web::scope("/service").service(services![fuzzysearch])) .service(web::scope("/chunk").service(services![chunk_add])) } diff --git a/src/models.rs b/src/models.rs index c014d16..1fe3e45 100644 --- a/src/models.rs +++ b/src/models.rs @@ -2534,6 +2534,52 @@ impl FileUploadChunk { } } +pub struct SiteAlert { + pub id: Uuid, + pub created_at: chrono::DateTime, + pub active: bool, + pub content: String, +} + +impl SiteAlert { + pub async fn active_for_user(conn: &sqlx::PgPool, user_id: Uuid) -> Result, Error> { + sqlx::query_file_as!(Self, "queries/site_alerts/active_for_user.sql", user_id) + .fetch_all(conn) + .await + .map_err(Into::into) + } + + pub async fn dismiss(conn: &sqlx::PgPool, alert_id: Uuid, user_id: Uuid) -> Result<(), Error> { + sqlx::query_file!("queries/site_alerts/dismiss.sql", alert_id, user_id) + .execute(conn) + .await?; + + Ok(()) + } + + pub async fn list(conn: &sqlx::PgPool) -> Result, Error> { + sqlx::query_file_as!(Self, "queries/site_alerts/list.sql") + .fetch_all(conn) + .await + .map_err(Into::into) + } + + pub async fn create(conn: &sqlx::PgPool, content: String) -> Result { + sqlx::query_file_scalar!("queries/site_alerts/create.sql", content) + .fetch_one(conn) + .await + .map_err(Into::into) + } + + pub async fn deactivate(conn: &sqlx::PgPool, alert_id: Uuid) -> Result<(), Error> { + sqlx::query_file!("queries/site_alerts/deactivate.sql", alert_id) + .execute(conn) + .await?; + + Ok(()) + } +} + pub trait UserSettingItem: Clone + Default + serde::Serialize + serde::de::DeserializeOwned { diff --git a/src/user.rs b/src/user.rs index 97daec9..5231644 100644 --- a/src/user.rs +++ b/src/user.rs @@ -24,6 +24,7 @@ use crate::{ #[template(path = "user/index.html")] struct Home<'a> { user: &'a models::User, + alerts: Vec, item_count: i64, total_content_size: i64, @@ -41,12 +42,14 @@ async fn home( let user_item_count = models::OwnedMediaItem::user_item_count(&conn, user.id); let recent_media = models::OwnedMediaItem::recent_media(&conn, user.id, None); let monitored_accounts = models::LinkedAccount::owned_by_user(&conn, user.id, false); + let alerts = models::SiteAlert::active_for_user(&conn, user.id); - let ((item_count, total_content_size), recent_media, monitored_accounts) = - futures::try_join!(user_item_count, recent_media, monitored_accounts)?; + let ((item_count, total_content_size), recent_media, monitored_accounts, alerts) = + futures::try_join!(user_item_count, recent_media, monitored_accounts, alerts)?; let body = Home { user: &user, + alerts, item_count, total_content_size, recent_media, diff --git a/templates/admin/_sidebar.html b/templates/admin/_sidebar.html index 584a2ae..ef20cab 100644 --- a/templates/admin/_sidebar.html +++ b/templates/admin/_sidebar.html @@ -14,6 +14,7 @@ diff --git a/templates/admin/alerts.html b/templates/admin/alerts.html new file mode 100644 index 0000000..bacfbf3 --- /dev/null +++ b/templates/admin/alerts.html @@ -0,0 +1,65 @@ +
+

Add Site Alert

+ +
+
+ +
+ +
+
+ +
+
+ +
+
+
+
+ +
+

Site Alerts

+ +
+ + + + + + + + + + + {% for alert in alerts %} + + + + + + + {% endfor %} + +
ActiveCreatedContent
+ {{ alert.active }} + + + {{ alert.created_at }} + + + {{ alert.content|markdown }} + +
+ + +
+ {% if alert.active %} + + {% endif %} +
+
+
+
+
diff --git a/templates/user/index.html b/templates/user/index.html index 5c2853c..5811ab1 100644 --- a/templates/user/index.html +++ b/templates/user/index.html @@ -2,6 +2,20 @@
+ {% for alert in alerts %} +
+ + +
+ {{ alert.content|markdown }} +
+ +

+ {{ alert.created_at }} +

+
+ {% endfor %} +

Welcome, {{ user.display_name() }}!