Add routes for getting/posting user reminders

This commit is contained in:
jude 2024-03-05 20:36:38 +00:00
parent dbe8e8e358
commit 5f0aa0f834
12 changed files with 381 additions and 66 deletions

2
Cargo.lock generated
View File

@ -942,6 +942,7 @@ checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
@ -2366,6 +2367,7 @@ dependencies = [
"dotenv",
"env_logger",
"extract_derive",
"futures",
"lazy-regex",
"lazy_static",
"levenshtein",

View File

@ -28,6 +28,7 @@ levenshtein = "1.0"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"] }
base64 = "0.21"
secrecy = "0.8.0"
futures = "0.3.30"
[dependencies.postman]
path = "postman"

View File

@ -182,3 +182,8 @@ export const fetchUserReminders = () => ({
axios.get(`/dashboard/api/user/reminders`).then((resp) => resp.data) as Promise<Reminder[]>,
staleTime: OTHER_STALE_TIME,
});
export const postUserReminder = () => ({
mutationFn: (reminder: Reminder) =>
axios.post(`/dashboard/api/user/reminders`, reminder).then((resp) => resp.data),
});

View File

@ -1,14 +1,15 @@
import { LoadTemplate } from "../LoadTemplate";
import { useReminder } from "../ReminderContext";
import { useMutation, useQueryClient } from "react-query";
import { postGuildReminder, postGuildTemplate } from "../../../api";
import { postGuildReminder, postGuildTemplate, postUserReminder } from "../../../api";
import { useParams } from "wouter";
import { useState } from "preact/hooks";
import { ICON_FLASH_TIME } from "../../../consts";
import { useFlash } from "../../App/FlashContext";
import { useGuild } from "../../App/useGuild";
export const CreateButtonRow = () => {
const { guild } = useParams();
const guild = useGuild();
const [reminder] = useReminder();
const [recentlyCreated, setRecentlyCreated] = useState(false);
@ -17,7 +18,7 @@ export const CreateButtonRow = () => {
const flash = useFlash();
const queryClient = useQueryClient();
const mutation = useMutation({
...postGuildReminder(guild),
...(guild ? postGuildReminder(guild) : postUserReminder()),
onSuccess: (data) => {
if (data.error) {
flash({
@ -29,9 +30,15 @@ export const CreateButtonRow = () => {
message: "Reminder created",
type: "success",
});
queryClient.invalidateQueries({
queryKey: ["GUILD_REMINDERS", guild],
});
if (guild) {
queryClient.invalidateQueries({
queryKey: ["GUILD_REMINDERS", guild],
});
} else {
queryClient.invalidateQueries({
queryKey: ["USER_REMINDERS"],
});
}
setRecentlyCreated(true);
setTimeout(() => {
setRecentlyCreated(false);

View File

@ -9,6 +9,7 @@ import { ReminderContext } from "./ReminderContext";
import { useQuery } from "react-query";
import { useParams } from "wouter";
import "./styles.scss";
import { useGuild } from "../App/useGuild";
function defaultReminder(): Reminder {
return {
@ -42,7 +43,7 @@ function defaultReminder(): Reminder {
}
export const CreateReminder = () => {
const { guild } = useParams();
const guild = useGuild();
const [reminder, setReminder] = useState(defaultReminder());
const [collapsed, setCollapsed] = useState(false);

View File

@ -7,8 +7,10 @@ import { useReminder } from "./ReminderContext";
import { Attachment } from "./Attachment";
import { TTS } from "./TTS";
import { TimeInput } from "./TimeInput";
import { useGuild } from "../App/useGuild";
export const Settings = () => {
const guild = useGuild();
const { isSuccess: userFetched, data: userInfo } = useQuery(fetchUserInfo());
const [reminder, setReminder] = useReminder();
@ -19,22 +21,24 @@ export const Settings = () => {
return (
<div class="column settings">
<div class="field channel-field">
<div class="collapses">
<label class="label" for="channelOption">
Channel*
</label>
{guild && (
<div class="field channel-field">
<div class="collapses">
<label class="label" for="channelOption">
Channel*
</label>
</div>
<ChannelSelector
channel={reminder.channel}
setChannel={(channel: string) => {
setReminder((reminder) => ({
...reminder,
channel: channel,
}));
}}
/>
</div>
<ChannelSelector
channel={reminder.channel}
setChannel={(channel: string) => {
setReminder((reminder) => ({
...reminder,
channel: channel,
}));
}}
/>
</div>
)}
<div class="field">
<div class="control">

View File

@ -35,8 +35,10 @@ type Database = MySql;
#[derive(Debug)]
enum Error {
SQLx,
Serenity,
#[allow(unused)]
SQLx(sqlx::Error),
#[allow(unused)]
Serenity(serenity::Error),
}
pub async fn initialize(
@ -132,6 +134,8 @@ pub async fn initialize(
routes::dashboard::api::user::get_user_info,
routes::dashboard::api::user::update_user_info,
routes::dashboard::api::user::get_user_guilds,
routes::dashboard::api::user::get_reminders,
routes::dashboard::api::user::create_user_reminder,
routes::dashboard::api::guild::get_guild_info,
routes::dashboard::api::guild::get_guild_channels,
routes::dashboard::api::guild::get_guild_roles,

View File

@ -106,7 +106,7 @@ pub async fn get_reminders(
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
INNER JOIN channels ON channels.id = reminders.channel_id
WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)",
channels
)

View File

@ -1,9 +1,12 @@
mod guilds;
mod models;
mod reminders;
use std::env;
use chrono_tz::Tz;
pub use guilds::*;
pub use reminders::*;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},

View File

@ -1,20 +1,230 @@
use std::env;
use chrono_tz::Tz;
use reqwest::Client;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},
State,
};
use chrono::{naive::NaiveDateTime, Utc};
use futures::TryFutureExt;
use rocket::serde::json::json;
use serde::{Deserialize, Serialize};
use serenity::{
client::Context,
model::{
id::{GuildId, RoleId},
permissions::Permissions,
},
};
use sqlx::{MySql, Pool};
use serenity::{client::Context, futures, model::id::UserId};
use sqlx::types::Json;
use crate::{consts::DISCORD_API, routes::JsonResult};
use crate::{
check_subscription,
consts::{
DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_NAME_LENGTH, MAX_URL_LENGTH,
MIN_INTERVAL,
},
guards::transaction::Transaction,
routes::{
dashboard::{create_database_channel, generate_uid, name_default, Attachment, EmbedField},
JsonResult,
},
Error,
};
#[derive(Serialize, Deserialize)]
pub struct Reminder {
pub attachment: Option<Attachment>,
pub attachment_name: Option<String>,
pub content: String,
pub embed_author: String,
pub embed_author_url: Option<String>,
pub embed_color: u32,
pub embed_description: String,
pub embed_footer: String,
pub embed_footer_url: Option<String>,
pub embed_image_url: Option<String>,
pub embed_thumbnail_url: Option<String>,
pub embed_title: String,
pub embed_fields: Option<Json<Vec<EmbedField>>>,
pub enabled: bool,
pub expires: Option<NaiveDateTime>,
pub interval_seconds: Option<u32>,
pub interval_days: Option<u32>,
pub interval_months: Option<u32>,
#[serde(default = "name_default")]
pub name: String,
pub tts: bool,
#[serde(default)]
pub uid: String,
pub utc_time: NaiveDateTime,
}
pub async fn create_reminder(
ctx: &Context,
transaction: &mut Transaction<'_>,
user_id: UserId,
reminder: Reminder,
) -> JsonResult {
let channel = user_id
.create_dm_channel(&ctx)
.map_err(|e| Error::Serenity(e))
.and_then(|dm_channel| create_database_channel(&ctx, dm_channel.id, transaction))
.await;
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
return Err(json!({"error": "Failed to configure channel for reminders."}));
}
let channel = channel.unwrap();
// validate lengths
check_length!(MAX_NAME_LENGTH, reminder.name);
check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
check_length_opt!(
MAX_URL_LENGTH,
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url
);
// validate urls
check_url_opt!(
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url
);
// validate time and interval
if reminder.utc_time < Utc::now().naive_utc() {
return Err(json!({"error": "Time must be in the future"}));
}
if reminder.interval_seconds.is_some()
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32
+ reminder.interval_days.unwrap_or(0) * DAY as u32
+ reminder.interval_seconds.unwrap_or(0)
< *MIN_INTERVAL
{
return Err(json!({"error": "Interval too short"}));
}
}
// check patreon if necessary
if reminder.interval_seconds.is_some()
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if !check_subscription(&ctx, user_id).await {
return Err(json!({"error": "Patreon is required to set intervals"}));
}
}
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
let new_uid = generate_uid();
// write to db
match sqlx::query!(
"INSERT INTO reminders (
uid,
attachment,
attachment_name,
channel_id,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
enabled,
expires,
interval_seconds,
interval_days,
interval_months,
name,
tts,
`utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new_uid,
reminder.attachment,
reminder.attachment_name,
channel,
reminder.content,
reminder.embed_author,
reminder.embed_author_url,
reminder.embed_color,
reminder.embed_description,
reminder.embed_footer,
reminder.embed_footer_url,
reminder.embed_image_url,
reminder.embed_thumbnail_url,
reminder.embed_title,
reminder.embed_fields,
reminder.enabled,
reminder.expires,
reminder.interval_seconds,
reminder.interval_days,
reminder.interval_months,
name,
reminder.tts,
reminder.utc_time,
)
.execute(transaction.executor())
.await
{
Ok(_) => 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
WHERE uid = ?",
new_uid
)
.fetch_one(transaction.executor())
.await
.map(|r| Ok(json!(r)))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
Err(json!({"error": "Could not load reminder"}))
}),
Err(e) => {
warn!("Error in `create_reminder`: Could not execute query: {:?}", e);
Err(json!({"error": "Unknown error"}))
}
}
}

View File

@ -1,23 +1,48 @@
use std::env;
use chrono_tz::Tz;
use reqwest::Client;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},
serde::json::{json, Json},
State,
};
use serde::{Deserialize, Serialize};
use serenity::{
client::Context,
model::{
id::{GuildId, RoleId},
permissions::Permissions,
},
};
use serenity::{client::Context, model::id::UserId};
use sqlx::{MySql, Pool};
use crate::{consts::DISCORD_API, routes::JsonResult};
use crate::{
guards::transaction::Transaction,
routes::{
dashboard::api::user::models::{create_reminder, Reminder},
JsonResult,
},
};
#[post("/api/user/reminders", data = "<reminder>")]
pub async fn create_user_reminder(
reminder: Json<Reminder>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
mut transaction: Transaction<'_>,
) -> JsonResult {
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
match create_reminder(
ctx.inner(),
&mut transaction,
UserId::new(user_id),
reminder.into_inner(),
)
.await
{
Ok(r) => match transaction.commit().await {
Ok(_) => Ok(r),
Err(e) => {
warn!("Couldn't commit transaction: {:?}", e);
json_err!("Couldn't commit transaction.")
}
},
Err(e) => Err(e),
}
}
#[get("/api/user/reminders")]
pub async fn get_reminders(
@ -25,5 +50,56 @@ pub async fn get_reminders(
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
Ok(json! {})
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
let channel = UserId::new(user_id).create_dm_channel(ctx.inner()).await;
match channel {
Ok(channel) => 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,
IFNULL(reminders.embed_fields, '[]') AS 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
INNER JOIN channels ON channels.id = reminders.channel_id
WHERE `status` = 'pending' AND channels.channel = ?
",
channel.id.get()
)
.fetch_all(pool.inner())
.await
.map(|r| Ok(json!(r)))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
json_err!("Could not load reminders")
}),
Err(e) => {
warn!("Couldn't get DM channel: {:?}", e);
json_err!("Could not find a DM channel")
}
}
}

View File

@ -55,7 +55,7 @@ fn interval_default() -> Unset<Option<u32>> {
#[derive(sqlx::Type)]
#[sqlx(transparent)]
struct Attachment(Vec<u8>);
pub struct Attachment(Vec<u8>);
impl<'de> Deserialize<'de> for Attachment {
fn deserialize<D>(deserializer: D) -> Result<Attachment, D::Error>
@ -605,11 +605,13 @@ async fn create_database_channel(
match row {
Ok(row) => {
if row.webhook_token.is_none() || row.webhook_id.is_none() {
let is_dm =
channel.to_channel(&ctx).await.map_err(|e| Error::Serenity(e))?.private().is_some();
if !is_dm && (row.webhook_token.is_none() || row.webhook_id.is_none()) {
let webhook = channel
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
.await
.map_err(|_| Error::Serenity)?;
.map_err(|e| Error::Serenity(e))?;
let token = webhook.token.unwrap();
@ -623,7 +625,7 @@ async fn create_database_channel(
)
.execute(transaction.executor())
.await
.map_err(|_| Error::SQLx)?;
.map_err(|e| Error::SQLx(e))?;
}
Ok(())
@ -634,7 +636,7 @@ async fn create_database_channel(
let webhook = channel
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
.await
.map_err(|_| Error::Serenity)?;
.map_err(|e| Error::Serenity(e))?;
let token = webhook.token.unwrap();
@ -653,18 +655,18 @@ async fn create_database_channel(
)
.execute(transaction.executor())
.await
.map_err(|_| Error::SQLx)?;
.map_err(|e| Error::SQLx(e))?;
Ok(())
}
Err(_) => Err(Error::SQLx),
Err(e) => Err(Error::SQLx(e)),
}?;
let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.get())
.fetch_one(transaction.executor())
.await
.map_err(|_| Error::SQLx)?;
.map_err(|e| Error::SQLx(e))?;
Ok(row.id)
}