Skip to main content

hvz/
notifications.rs

1use crate::{
2    establish_connection,
3    player::{PlayerRole, PlayerState},
4    schema::{notifications, players, subscriptions},
5    vapid_keys,
6};
7use chrono::NaiveDateTime;
8use diesel::{
9    ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl,
10    backend::Backend,
11    deserialize::{self, FromSql, FromSqlRow},
12    expression::AsExpression,
13    mysql::Mysql,
14    prelude::{Insertable, Queryable},
15    serialize::{self, IsNull, Output, ToSql},
16    sql_types::Text,
17};
18use log::info;
19use serde::{Deserialize, Serialize};
20use uuid::Uuid;
21use web_push::{
22    IsahcWebPushClient, SubscriptionInfo, SubscriptionKeys, VapidSignatureBuilder, WebPushClient,
23    WebPushMessageBuilder,
24};
25
26/// PlayerGroups represents different groups of players in the game.
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, FromSqlRow, AsExpression)]
28#[diesel(sql_type = Text)]
29pub enum PlayerGroups {
30    All,
31    Humans,
32    Zombies,
33    Mods,
34    Individuals(String),
35}
36
37impl FromSql<Text, Mysql> for PlayerGroups {
38    /// Converts a string representation of a player group into a PlayerGroups enum.
39    fn from_sql<'a>(string: <Mysql as Backend>::RawValue<'a>) -> deserialize::Result<Self> {
40        match str::from_utf8(string.as_bytes())? {
41            "All" => Ok(PlayerGroups::All),
42            "Humans" => Ok(PlayerGroups::Humans),
43            "Zombies" => Ok(PlayerGroups::Zombies),
44            "Mods" => Ok(PlayerGroups::Mods),
45            str => Ok(PlayerGroups::Individuals(str.to_string())),
46        }
47    }
48}
49
50impl ToSql<Text, Mysql> for PlayerGroups {
51    /// Converts a PlayerGroups enum into a string representation.
52    fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Mysql>) -> serialize::Result {
53        match self {
54            PlayerGroups::All => <str as ToSql<Text, Mysql>>::to_sql("All", out)?,
55            PlayerGroups::Humans => <str as ToSql<Text, Mysql>>::to_sql("Humans", out)?,
56            PlayerGroups::Zombies => <str as ToSql<Text, Mysql>>::to_sql("Zombies", out)?,
57            PlayerGroups::Mods => <str as ToSql<Text, Mysql>>::to_sql("Mods", out)?,
58            PlayerGroups::Individuals(id) => <String as ToSql<Text, Mysql>>::to_sql(id, out)?,
59        };
60        Ok(IsNull::No)
61    }
62}
63
64#[derive(Debug, Serialize, Deserialize, Insertable, Clone, Queryable)]
65#[diesel(table_name = crate::schema::subscriptions)]
66#[diesel(check_for_backend(diesel::mysql::Mysql))]
67pub struct PushSubscription {
68    pub id: String,
69    pub player_id: String,
70    pub endpoint: String,
71    pub p256dh: String,
72    pub auth: String,
73    pub date: NaiveDateTime,
74}
75
76impl PushSubscription {
77    pub async fn new(player_id: String, endpoint: String, p256dh: String, auth: String) -> Self {
78        PushSubscription {
79            id: Uuid::new_v4().to_string(),
80            player_id,
81            endpoint,
82            p256dh,
83            auth,
84            date: chrono::Utc::now().naive_utc(),
85        }
86    }
87
88    pub async fn save(&self) -> Result<Self, String> {
89        let connection = &mut establish_connection();
90
91        diesel::insert_into(subscriptions::table)
92            .values(self.clone())
93            .execute(connection)
94            .expect("Failed to save subscription");
95
96        Ok(self.clone())
97    }
98
99    pub async fn get_subscription(player_id: String) -> Result<Option<Self>, String> {
100        let connection = &mut establish_connection();
101
102        subscriptions::table
103            .filter(subscriptions::player_id.eq(player_id))
104            .first::<PushSubscription>(connection)
105            .optional()
106            .map_err(|e| format!("Failed to get push subscription: {}", e))
107    }
108
109    pub async fn get_all_subscriptions(
110        player_group: PlayerGroups,
111    ) -> Result<Option<Vec<Self>>, String> {
112        let connection = &mut establish_connection();
113
114        let player_ids = match player_group {
115            PlayerGroups::All => players::table
116                .select(players::id)
117                .load::<String>(connection)
118                .map_err(|e| format!("Failed to load players: {}", e)),
119            PlayerGroups::Humans => players::table
120                .filter(players::state.eq(PlayerState::Human))
121                .select(players::id)
122                .load::<String>(connection)
123                .map_err(|e| format!("Failed to load players: {}", e)),
124            PlayerGroups::Zombies => players::table
125                .filter(players::state.eq(PlayerState::Zombie))
126                .select(players::id)
127                .load::<String>(connection)
128                .map_err(|e| format!("Failed to load players: {}", e)),
129            PlayerGroups::Mods => players::table
130                .filter(players::role.eq(PlayerRole::Mod))
131                .select(players::id)
132                .load::<String>(connection)
133                .map_err(|e| format!("Failed to load players: {}", e)),
134            PlayerGroups::Individuals(id) => players::table
135                .filter(players::id.eq(id))
136                .select(players::id)
137                .load::<String>(connection)
138                .map_err(|e| format!("Failed to load players: {}", e)),
139        }?;
140
141        subscriptions::table
142            .filter(subscriptions::player_id.eq_any(player_ids))
143            .load::<PushSubscription>(connection)
144            .optional()
145            .map_err(|e| format!("Failed to get push subscription: {}", e))
146    }
147
148    pub async fn unsubscribe(player_id: String) -> Result<usize, String> {
149        let connection = &mut establish_connection();
150
151        diesel::delete(subscriptions::table.filter(subscriptions::player_id.eq(player_id)))
152            .execute(connection)
153            .map_err(|e| format!("Failed to unsubscribe player: {}", e))
154    }
155}
156
157/// Represents a notification sent to players.
158#[derive(Debug, Serialize, Deserialize, Insertable, Clone)]
159#[diesel(table_name = crate::schema::notifications)]
160#[diesel(check_for_backend(diesel::mysql::Mysql))]
161pub struct Notification {
162    pub id: String,
163    pub player: String,
164    pub title: String,
165    pub content: String,
166    pub player_group: PlayerGroups,
167    pub date: NaiveDateTime,
168}
169
170impl Notification {
171    pub async fn new(
172        player: String,
173        title: String,
174        content: String,
175        player_groups: PlayerGroups,
176    ) -> Self {
177        Notification {
178            id: Uuid::new_v4().to_string(),
179            player,
180            title,
181            content,
182            player_group: player_groups,
183            date: chrono::Utc::now().naive_utc(),
184        }
185    }
186
187    pub async fn save(&self) -> Result<Notification, String> {
188        let connection = &mut establish_connection();
189
190        diesel::insert_into(notifications::table)
191            .values(self.clone())
192            .execute(connection)
193            .expect("Error inserting notification");
194
195        info!("Notification {:?} created successfully", self.id);
196
197        Ok(self.clone())
198    }
199
200    pub async fn send_push_notification(&self) -> Result<String, String> {
201        let (vapid_private_key, _, vapid_email) = vapid_keys()?;
202
203        let subscriptions =
204            match PushSubscription::get_all_subscriptions(self.player_group.clone()).await? {
205                Some(subscriptions) => subscriptions,
206                None => {
207                    return Err("No push subscriptions".to_string());
208                }
209            };
210
211        for sub in subscriptions {
212            let subscription_info = SubscriptionInfo {
213                endpoint: sub.endpoint,
214                keys: SubscriptionKeys {
215                    p256dh: sub.p256dh,
216                    auth: sub.auth,
217                },
218            };
219            let mut builder = WebPushMessageBuilder::new(&subscription_info);
220            let payload = serde_json::json!({
221                "title": self.title,
222                "content": self.content
223            })
224            .to_string();
225            builder.set_payload(web_push::ContentEncoding::AesGcm, payload.as_bytes());
226
227            let mut signature_builder =
228                VapidSignatureBuilder::from_base64(&vapid_private_key, &subscription_info)
229                    .map_err(|e| e.to_string())?;
230            signature_builder.add_claim("sub", vapid_email.clone());
231
232            builder.set_vapid_signature(signature_builder.build().map_err(|e| e.to_string())?);
233
234            let client = IsahcWebPushClient::new().map_err(|e| e.to_string())?;
235
236            client
237                .send(builder.build().map_err(|e| e.to_string())?)
238                .await
239                .map_err(|e| e.to_string())?;
240        }
241        Ok(String::from("Notifications sent successfully"))
242    }
243}