Extend user reminder API endpoints

This commit is contained in:
jude 2024-03-09 16:17:55 +00:00
parent 8f4810b532
commit 3190738fc5
11 changed files with 342 additions and 75 deletions

2
Cargo.lock generated
View File

@ -2359,7 +2359,7 @@ checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
[[package]] [[package]]
name = "reminder-rs" name = "reminder-rs"
version = "1.7.1" version = "1.7.2"
dependencies = [ dependencies = [
"base64 0.21.7", "base64 0.21.7",
"chrono", "chrono",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "reminder-rs" name = "reminder-rs"
version = "1.7.1" version = "1.7.2"
authors = ["Jude Southworth <judesouthworth@pm.me>"] authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021" edition = "2021"
license = "AGPL-3.0 only" license = "AGPL-3.0 only"

View File

@ -144,9 +144,9 @@ export const postGuildReminder = (guild: string) => ({
axios.post(`/dashboard/api/guild/${guild}/reminders`, reminder).then((resp) => resp.data), axios.post(`/dashboard/api/guild/${guild}/reminders`, reminder).then((resp) => resp.data),
}); });
export const deleteGuildReminder = (guild: string) => ({ export const deleteReminder = () => ({
mutationFn: (reminder: Reminder) => mutationFn: (reminder: Reminder) =>
axios.delete(`/dashboard/api/guild/${guild}/reminders`, { axios.delete(`/dashboard/api/reminders`, {
data: { data: {
uid: reminder.uid, uid: reminder.uid,
}, },
@ -187,3 +187,7 @@ export const postUserReminder = () => ({
mutationFn: (reminder: Reminder) => mutationFn: (reminder: Reminder) =>
axios.post(`/dashboard/api/user/reminders`, reminder).then((resp) => resp.data), axios.post(`/dashboard/api/user/reminders`, reminder).then((resp) => resp.data),
}); });
export const patchUserReminder = () => ({
mutationFn: (reminder: Reminder) => axios.patch(`/dashboard/api/user/reminders`, reminder),
});

View File

@ -2,7 +2,6 @@ import { LoadTemplate } from "../LoadTemplate";
import { useReminder } from "../ReminderContext"; import { useReminder } from "../ReminderContext";
import { useMutation, useQueryClient } from "react-query"; import { useMutation, useQueryClient } from "react-query";
import { postGuildReminder, postGuildTemplate, postUserReminder } from "../../../api"; import { postGuildReminder, postGuildTemplate, postUserReminder } from "../../../api";
import { useParams } from "wouter";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { ICON_FLASH_TIME } from "../../../consts"; import { ICON_FLASH_TIME } from "../../../consts";
import { useFlash } from "../../App/FlashContext"; import { useFlash } from "../../App/FlashContext";
@ -96,34 +95,36 @@ export const CreateButtonRow = () => {
)} )}
</button> </button>
</div> </div>
<div class="button-row-template"> {guild && (
<div> <div class="button-row-template">
<button <div>
class="button is-success is-outlined" <button
onClick={() => { class="button is-success is-outlined"
templateMutation.mutate(reminder); onClick={() => {
}} templateMutation.mutate(reminder);
> }}
<span>Create Template</span>{" "} >
{templateMutation.isLoading ? ( <span>Create Template</span>{" "}
<span class="icon"> {templateMutation.isLoading ? (
<i class="fas fa-spin fa-cog"></i> <span class="icon">
</span> <i class="fas fa-spin fa-cog"></i>
) : templateRecentlyCreated ? ( </span>
<span class="icon"> ) : templateRecentlyCreated ? (
<i class="fas fa-check"></i> <span class="icon">
</span> <i class="fas fa-check"></i>
) : ( </span>
<span class="icon"> ) : (
<i class="fas fa-file-spreadsheet"></i> <span class="icon">
</span> <i class="fas fa-file-spreadsheet"></i>
)} </span>
</button> )}
</button>
</div>
<div>
<LoadTemplate />
</div>
</div> </div>
<div> )}
<LoadTemplate />
</div>
</div>
</div> </div>
); );
}; };

View File

@ -2,9 +2,10 @@ import { useState } from "preact/hooks";
import { Modal } from "../../Modal"; import { Modal } from "../../Modal";
import { useMutation, useQueryClient } from "react-query"; import { useMutation, useQueryClient } from "react-query";
import { useReminder } from "../ReminderContext"; import { useReminder } from "../ReminderContext";
import { deleteGuildReminder } from "../../../api"; import { deleteReminder } from "../../../api";
import { useParams } from "wouter"; import { useParams } from "wouter";
import { useFlash } from "../../App/FlashContext"; import { useFlash } from "../../App/FlashContext";
import { useGuild } from "../../App/useGuild";
export const DeleteButton = () => { export const DeleteButton = () => {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
@ -26,20 +27,26 @@ export const DeleteButton = () => {
const DeleteModal = ({ setModalOpen }) => { const DeleteModal = ({ setModalOpen }) => {
const [reminder] = useReminder(); const [reminder] = useReminder();
const { guild } = useParams(); const guild = useGuild();
const flash = useFlash(); const flash = useFlash();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
...deleteGuildReminder(guild), ...deleteReminder(),
onSuccess: () => { onSuccess: () => {
flash({ flash({
message: "Reminder deleted", message: "Reminder deleted",
type: "success", type: "success",
}); });
queryClient.invalidateQueries({ if (guild) {
queryKey: ["GUILD_REMINDERS", guild], queryClient.invalidateQueries({
}); queryKey: ["GUILD_REMINDERS", guild],
});
} else {
queryClient.invalidateQueries({
queryKey: ["USER_REMINDERS"],
});
}
setModalOpen(false); setModalOpen(false);
}, },
}); });

View File

@ -1,14 +1,14 @@
import { useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import { useMutation, useQueryClient } from "react-query"; import { useMutation, useQueryClient } from "react-query";
import { patchGuildReminder } from "../../../api"; import { patchGuildReminder, patchUserReminder } from "../../../api";
import { useParams } from "wouter";
import { ICON_FLASH_TIME } from "../../../consts"; import { ICON_FLASH_TIME } from "../../../consts";
import { DeleteButton } from "./DeleteButton"; import { DeleteButton } from "./DeleteButton";
import { useReminder } from "../ReminderContext"; import { useReminder } from "../ReminderContext";
import { useFlash } from "../../App/FlashContext"; import { useFlash } from "../../App/FlashContext";
import { useGuild } from "../../App/useGuild";
export const EditButtonRow = () => { export const EditButtonRow = () => {
const { guild } = useParams(); const guild = useGuild();
const [reminder, setReminder] = useReminder(); const [reminder, setReminder] = useReminder();
const [recentlySaved, setRecentlySaved] = useState(false); const [recentlySaved, setRecentlySaved] = useState(false);
@ -18,11 +18,17 @@ export const EditButtonRow = () => {
const flash = useFlash(); const flash = useFlash();
const mutation = useMutation({ const mutation = useMutation({
...patchGuildReminder(guild), ...(guild ? patchGuildReminder(guild) : patchUserReminder()),
onSuccess: (response) => { onSuccess: (response) => {
queryClient.invalidateQueries({ if (guild) {
queryKey: ["GUILD_REMINDERS", guild], queryClient.invalidateQueries({
}); queryKey: ["GUILD_REMINDERS", guild],
});
} else {
queryClient.invalidateQueries({
queryKey: ["USER_REMINDERS"],
});
}
if (iconFlashTimeout.current !== null) { if (iconFlashTimeout.current !== null) {
clearTimeout(iconFlashTimeout.current); clearTimeout(iconFlashTimeout.current);

View File

@ -131,6 +131,7 @@ pub async fn initialize(
routes![ routes![
routes::dashboard::dashboard, routes::dashboard::dashboard,
routes::dashboard::dashboard_home, routes::dashboard::dashboard_home,
routes::dashboard::api::delete_reminder,
routes::dashboard::api::user::get_user_info, routes::dashboard::api::user::get_user_info,
routes::dashboard::api::user::update_user_info, routes::dashboard::api::user::update_user_info,
routes::dashboard::api::user::get_user_guilds, routes::dashboard::api::user::get_user_guilds,
@ -145,7 +146,6 @@ pub async fn initialize(
routes::dashboard::api::guild::create_guild_reminder, routes::dashboard::api::guild::create_guild_reminder,
routes::dashboard::api::guild::get_reminders, routes::dashboard::api::guild::get_reminders,
routes::dashboard::api::guild::edit_reminder, routes::dashboard::api::guild::edit_reminder,
routes::dashboard::api::guild::delete_reminder,
routes::dashboard::export::export_reminders, routes::dashboard::export::export_reminders,
routes::dashboard::export::export_reminder_templates, routes::dashboard::export::export_reminder_templates,
routes::dashboard::export::export_todos, routes::dashboard::export::export_todos,

View File

@ -14,9 +14,7 @@ use crate::{
consts::MIN_INTERVAL, consts::MIN_INTERVAL,
guards::transaction::Transaction, guards::transaction::Transaction,
routes::{ routes::{
dashboard::{ dashboard::{create_database_channel, create_reminder, PatchReminder, Reminder},
create_database_channel, create_reminder, DeleteReminder, PatchReminder, Reminder,
},
JsonResult, JsonResult,
}, },
Database, Database,
@ -364,27 +362,3 @@ pub async fn edit_reminder(
} }
} }
} }
#[delete("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn delete_reminder(
cookies: &CookieJar<'_>,
id: u64,
reminder: Json<DeleteReminder>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
match sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", reminder.uid)
.execute(pool.inner())
.await
{
Ok(_) => Ok(json!({})),
Err(e) => {
warn!("Error in `delete_reminder`: {:?}", e);
Err(json!({"error": "Could not delete reminder"}))
}
}
}

View File

@ -1,2 +1,41 @@
pub mod guild; pub mod guild;
pub mod user; pub mod user;
use rocket::{
http::CookieJar,
serde::json::{json, Json},
State,
};
use serenity::client::Context;
use sqlx::{MySql, Pool};
use crate::routes::{dashboard::DeleteReminder, JsonResult};
#[delete("/api/reminders", data = "<reminder>")]
pub async fn delete_reminder(
cookies: &CookieJar<'_>,
reminder: Json<DeleteReminder>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
match cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten() {
Some(_) => {
match sqlx::query!(
"UPDATE reminders SET `status` = 'deleted' WHERE uid = ?",
reminder.uid
)
.execute(pool.inner())
.await
{
Ok(_) => Ok(json!({})),
Err(e) => {
warn!("Error in `delete_reminder`: {:?}", e);
Err(json!({"error": "Could not delete reminder"}))
}
}
}
None => Err(json!({"error": "User not authorized"})),
}
}

View File

@ -15,7 +15,10 @@ use crate::{
}, },
guards::transaction::Transaction, guards::transaction::Transaction,
routes::{ routes::{
dashboard::{create_database_channel, generate_uid, name_default, Attachment, EmbedField}, dashboard::{
create_database_channel, deserialize_optional_field, generate_uid, interval_default,
name_default, Attachment, EmbedField, Unset,
},
JsonResult, JsonResult,
}, },
Error, Error,
@ -228,3 +231,60 @@ pub async fn create_reminder(
} }
} }
} }
#[derive(Deserialize)]
pub struct PatchReminder {
pub uid: String,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
pub attachment: Unset<Option<Attachment>>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
pub attachment_name: Unset<Option<String>>,
#[serde(default)]
pub content: Unset<String>,
#[serde(default)]
pub embed_author: Unset<String>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
pub embed_author_url: Unset<Option<String>>,
#[serde(default)]
pub embed_color: Unset<u32>,
#[serde(default)]
pub embed_description: Unset<String>,
#[serde(default)]
pub embed_footer: Unset<String>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
pub embed_footer_url: Unset<Option<String>>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
pub embed_image_url: Unset<Option<String>>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
pub embed_thumbnail_url: Unset<Option<String>>,
#[serde(default)]
pub embed_title: Unset<String>,
#[serde(default)]
pub embed_fields: Unset<Json<Vec<EmbedField>>>,
#[serde(default)]
pub enabled: Unset<bool>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
pub expires: Unset<Option<NaiveDateTime>>,
#[serde(default = "interval_default")]
#[serde(deserialize_with = "deserialize_optional_field")]
pub interval_seconds: Unset<Option<u32>>,
#[serde(default = "interval_default")]
#[serde(deserialize_with = "deserialize_optional_field")]
pub interval_days: Unset<Option<u32>>,
#[serde(default = "interval_default")]
#[serde(deserialize_with = "deserialize_optional_field")]
pub interval_months: Unset<Option<u32>>,
#[serde(default)]
pub name: Unset<String>,
#[serde(default)]
pub tts: Unset<bool>,
#[serde(default)]
pub utc_time: Unset<NaiveDateTime>,
}

View File

@ -7,11 +7,16 @@ use serenity::{client::Context, model::id::UserId};
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use crate::{ use crate::{
check_subscription,
guards::transaction::Transaction, guards::transaction::Transaction,
routes::{ routes::{
dashboard::api::user::models::{create_reminder, Reminder}, dashboard::{
api::user::models::{create_reminder, Reminder},
PatchReminder, MIN_INTERVAL,
},
JsonResult, JsonResult,
}, },
Database,
}; };
#[post("/api/user/reminders", data = "<reminder>")] #[post("/api/user/reminders", data = "<reminder>")]
@ -103,3 +108,174 @@ pub async fn get_reminders(
} }
} }
} }
#[patch("/api/user/reminders", data = "<reminder>")]
pub async fn edit_reminder(
reminder: Json<PatchReminder>,
ctx: &State<Context>,
mut transaction: Transaction<'_>,
pool: &State<Pool<Database>>,
cookies: &CookieJar<'_>,
) -> JsonResult {
let user_id_cookie =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten();
if user_id_cookie.is_none() {
return Err(json!({"error": "User not authorized"}));
}
let mut error = vec![];
let user_id = user_id_cookie.unwrap();
if reminder.message_ok() {
update_field!(transaction.executor(), error, reminder.[
content,
embed_author,
embed_description,
embed_footer,
embed_title,
embed_fields
]);
} else {
error.push("Message exceeds limits.".to_string());
}
update_field!(transaction.executor(), error, reminder.[
attachment,
attachment_name,
embed_author_url,
embed_color,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
enabled,
expires,
name,
tts,
utc_time
]);
if reminder.interval_days.flatten().is_some()
|| reminder.interval_months.flatten().is_some()
|| reminder.interval_seconds.flatten().is_some()
{
if check_subscription(&ctx.inner(), user_id).await {
let new_interval_length = match reminder.interval_days {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_days AS days FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(transaction.executor())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.days
.unwrap_or(0),
} * 86400 + match reminder.interval_months {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_months AS months FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(transaction.executor())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.months
.unwrap_or(0),
} * 2592000 + match reminder.interval_seconds {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_seconds AS seconds FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(transaction.executor())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.seconds
.unwrap_or(0),
};
if new_interval_length < *MIN_INTERVAL {
error.push(String::from("New interval is too short."));
} else {
update_field!(transaction.executor(), error, reminder.[
interval_days,
interval_months,
interval_seconds
]);
}
}
} else {
sqlx::query!(
"
UPDATE reminders
SET interval_seconds = NULL, interval_days = NULL, interval_months = NULL
WHERE uid = ?
",
reminder.uid
)
.execute(transaction.executor())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?;
}
if let Err(e) = transaction.commit().await {
warn!("Couldn't commit transaction: {:?}", e);
return json_err!("Couldn't commit transaction");
}
match sqlx::query_as_unchecked!(
Reminder,
"
SELECT reminders.attachment,
reminders.attachment_name,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
reminders.embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.tts,
reminders.uid,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE uid = ?
",
reminder.uid
)
.fetch_one(pool.inner())
.await
{
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
Err(e) => {
warn!("Error exiting `edit_reminder': {:?}", e);
Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]}))
}
}
}