use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::envelope::{Ack, ServerPush, WsCall};
use crate::error::{ApiError, ValidationDetails};
use crate::ids::{NotificationId, UserId};
use crate::kinds;
use crate::page::{Cursor, Page};
use crate::time::UnixMillis;
pub const MAX_KIND_BYTES: usize = 64;
pub const MAX_TEXT_CHARS: usize = 1000;
pub const DEFAULT_MAX_DATA_BYTES: usize = 4096;
pub const MAX_MARK_IDS: usize = 100;
pub fn is_valid_kind(kind: &str) -> bool {
let bytes = kind.as_bytes();
bytes.len() <= MAX_KIND_BYTES
&& bytes.first().is_some_and(u8::is_ascii_lowercase)
&& bytes.iter().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b':' | b'-'))
}
pub fn text_problem(text: &str) -> Option<String> {
if text.trim().is_empty() {
return Some("is empty".into());
}
if text.chars().count() > MAX_TEXT_CHARS {
return Some(format!("is longer than {MAX_TEXT_CHARS} characters"));
}
crate::text::message_problem(text).map(str::to_string)
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Notification {
pub id: NotificationId,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sender: Option<UserId>,
pub created_at: UnixMillis,
#[serde(default)]
pub read: bool,
}
impl Notification {
pub fn new(id: NotificationId, kind: impl Into<String>, created_at: UnixMillis) -> Self {
Self { id, kind: kind.into(), text: None, data: None, sender: None, created_at, read: false }
}
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.text = Some(text.into());
self
}
pub fn with_data(mut self, data: Value) -> Self {
self.data = Some(data);
self
}
pub fn with_sender(mut self, sender: UserId) -> Self {
self.sender = Some(sender);
self
}
pub fn with_read(mut self, read: bool) -> Self {
self.read = read;
self
}
}
impl ServerPush for Notification {
const KIND: &'static str = kinds::NOTIFY_NEW;
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NotificationQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<Cursor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unread_only: Option<bool>,
}
impl NotificationQuery {
pub fn new() -> Self {
Self::default()
}
pub fn after(mut self, cursor: Cursor) -> Self {
self.cursor = Some(cursor);
self
}
pub fn with_limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
pub fn unread_only(mut self) -> Self {
self.unread_only = Some(true);
self
}
pub fn limit_or_default(&self) -> u32 {
self.limit.map_or(crate::page::DEFAULT_PAGE_LIMIT, |limit| limit.clamp(1, crate::page::MAX_PAGE_LIMIT))
}
}
impl WsCall for NotificationQuery {
type Response = Page<Notification>;
const KIND: &'static str = kinds::NOTIFY_LIST;
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CountNotifications {}
impl CountNotifications {
pub fn new() -> Self {
Self {}
}
}
impl WsCall for CountNotifications {
type Response = NotificationCount;
const KIND: &'static str = kinds::NOTIFY_COUNT;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NotificationCount {
pub unread: u64,
pub total: u64,
}
impl NotificationCount {
pub fn new(unread: u64, total: u64) -> Self {
Self { unread, total }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MarkNotifications {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ids: Vec<NotificationId>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub all: bool,
pub read: bool,
}
impl MarkNotifications {
pub fn read(ids: Vec<NotificationId>) -> Self {
Self { ids, all: false, read: true }
}
pub fn unread(ids: Vec<NotificationId>) -> Self {
Self { ids, all: false, read: false }
}
pub fn all_read() -> Self {
Self { ids: Vec::new(), all: true, read: true }
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if self.all && !self.ids.is_empty() {
details.add("ids", "must be empty with all");
}
if !self.all && !(1..=MAX_MARK_IDS).contains(&self.ids.len()) {
details.add("ids", format!("must hold 1 to {MAX_MARK_IDS} ids (or set all)"));
}
let distinct: HashSet<NotificationId> = self.ids.iter().copied().collect();
if distinct.len() != self.ids.len() {
details.add("ids", "must be distinct");
}
details.into_result()
}
}
impl WsCall for MarkNotifications {
type Response = MarkAck;
const KIND: &'static str = kinds::NOTIFY_MARK;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MarkAck {
pub changed: u64,
pub unread: u64,
}
impl MarkAck {
pub fn new(changed: u64, unread: u64) -> Self {
Self { changed, unread }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct DeleteNotification {
pub id: NotificationId,
}
impl DeleteNotification {
pub fn new(id: NotificationId) -> Self {
Self { id }
}
}
impl WsCall for DeleteNotification {
type Response = Ack;
const KIND: &'static str = kinds::NOTIFY_DELETE;
}
mod calls {
use super::*;
use crate::http_call::{payload_call, HttpCall, NoPayload, PathParams, PayloadKind, NO_PAYLOAD};
use crate::routes::{self, HttpMethod, Route};
payload_call!(NotificationQuery, Get, routes::notifications::LIST, true, Query, Page<Notification>);
payload_call!(CountNotifications, Get, routes::notifications::COUNT, true, Query, NotificationCount);
payload_call!(MarkNotifications, Post, routes::notifications::MARK, true, Json, MarkAck);
impl HttpCall for DeleteNotification {
type Payload = NoPayload;
type Response = Ack;
const ROUTE: Route = Route::new(HttpMethod::Delete, routes::notifications::ONE, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn path_params(&self) -> PathParams {
PathParams::new().with("id", self.id)
}
fn from_parts(params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new(params.id("id")?))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kinds_and_texts() {
for good in ["reward", "invite.match", "a:b-c_d.9", &"k".repeat(MAX_KIND_BYTES)] {
assert!(is_valid_kind(good), "{good}");
}
for bad in ["", "Reward", "9x", "-x", "a b", "a/b", &"k".repeat(MAX_KIND_BYTES + 1)] {
assert!(!is_valid_kind(bad), "{bad}");
}
assert!(text_problem("You won!").is_none());
assert!(text_problem(" ").is_some() && text_problem(&"é".repeat(MAX_TEXT_CHARS + 1)).is_some());
assert!(text_problem("a\u{202E}b").is_some());
}
#[test]
fn json_and_rules() {
let n = Notification::new(NotificationId(31), "reward", UnixMillis(5)).with_data(serde_json::json!({"gold": 50}));
assert_eq!(serde_json::to_string(&n).ok().as_deref(), Some(r#"{"id":31,"kind":"reward","data":{"gold":50},"created_at":5,"read":false}"#));
assert_eq!(serde_json::to_string(&MarkNotifications::all_read()).ok().as_deref(), Some(r#"{"all":true,"read":true}"#));
assert_eq!(serde_json::to_string(&MarkNotifications::read(vec![NotificationId(1)])).ok().as_deref(), Some(r#"{"ids":[1],"read":true}"#));
assert!(MarkNotifications::all_read().validate().is_ok());
assert!(MarkNotifications::read(vec![]).validate().is_err());
assert!(MarkNotifications::read(vec![NotificationId(1), NotificationId(1)]).validate().is_err());
assert!(MarkNotifications::read((0..=MAX_MARK_IDS as i64).map(NotificationId).collect()).validate().is_err());
let mut both = MarkNotifications::all_read();
both.ids.push(NotificationId(1));
assert!(both.validate().is_err());
assert_eq!(serde_json::to_string(&CountNotifications::new()).ok().as_deref(), Some("{}"));
assert_eq!(serde_json::from_str::<CountNotifications>("{}").ok(), Some(CountNotifications::new()));
assert_eq!(NotificationQuery::new().with_limit(0).limit_or_default(), 1);
}
}