use super::ids::*; use crate::database::models::notification_item::DBNotification; use crate::database::models::notification_item::DBNotificationAction; use crate::database::models::notifications_deliveries_item::DBNotificationDelivery; use crate::models::billing::PriceDuration; use crate::models::ids::{ NotificationId, ProjectId, ReportId, TeamId, ThreadId, ThreadMessageId, VersionId, }; use crate::models::projects::ProjectStatus; use crate::routes::ApiError; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] pub struct Notification { pub id: NotificationId, pub user_id: UserId, pub read: bool, pub created: DateTime, pub body: NotificationBody, pub name: String, pub text: String, pub link: String, pub actions: Vec, } #[derive(Serialize, Deserialize, Clone, Copy, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum NotificationType { // If adding a notification type, add a variant in `NotificationBody` of the same name! ProjectUpdate, TeamInvite, OrganizationInvite, StatusChange, ModeratorMessage, LegacyMarkdown, ResetPassword, VerifyEmail, AuthProviderAdded, AuthProviderRemoved, TwoFactorEnabled, TwoFactorRemoved, PasswordChanged, PasswordRemoved, EmailChanged, PaymentFailed, TaxNotification, PatCreated, ModerationMessageReceived, ReportStatusUpdated, ReportSubmitted, ProjectStatusApproved, ProjectStatusNeutral, ProjectTransferred, PayoutAvailable, Unknown, } impl NotificationType { pub fn as_str(self) -> &'static str { match self { NotificationType::ProjectUpdate => "project_update", NotificationType::TeamInvite => "team_invite", NotificationType::OrganizationInvite => "organization_invite", NotificationType::StatusChange => "status_change", NotificationType::ModeratorMessage => "moderator_message", NotificationType::LegacyMarkdown => "legacy_markdown", NotificationType::ResetPassword => "reset_password", NotificationType::VerifyEmail => "verify_email", NotificationType::AuthProviderAdded => "auth_provider_added", NotificationType::AuthProviderRemoved => "auth_provider_removed", NotificationType::TwoFactorEnabled => "two_factor_enabled", NotificationType::TwoFactorRemoved => "two_factor_removed", NotificationType::PasswordChanged => "password_changed", NotificationType::PasswordRemoved => "password_removed", NotificationType::EmailChanged => "email_changed", NotificationType::PaymentFailed => "payment_failed", NotificationType::TaxNotification => "tax_notification", NotificationType::PatCreated => "pat_created", NotificationType::PayoutAvailable => "payout_available", NotificationType::ModerationMessageReceived => { "moderation_message_received" } NotificationType::ReportStatusUpdated => "report_status_updated", NotificationType::ReportSubmitted => "report_submitted", NotificationType::ProjectStatusApproved => { "project_status_approved" } NotificationType::ProjectStatusNeutral => "project_status_neutral", NotificationType::ProjectTransferred => "project_transferred", NotificationType::Unknown => "unknown", } } pub fn from_str_or_default(s: &str) -> Self { match s { "project_update" => NotificationType::ProjectUpdate, "team_invite" => NotificationType::TeamInvite, "organization_invite" => NotificationType::OrganizationInvite, "status_change" => NotificationType::StatusChange, "moderator_message" => NotificationType::ModeratorMessage, "legacy_markdown" => NotificationType::LegacyMarkdown, "reset_password" => NotificationType::ResetPassword, "verify_email" => NotificationType::VerifyEmail, "auth_provider_added" => NotificationType::AuthProviderAdded, "auth_provider_removed" => NotificationType::AuthProviderRemoved, "two_factor_enabled" => NotificationType::TwoFactorEnabled, "two_factor_removed" => NotificationType::TwoFactorRemoved, "password_changed" => NotificationType::PasswordChanged, "password_removed" => NotificationType::PasswordRemoved, "email_changed" => NotificationType::EmailChanged, "payment_failed" => NotificationType::PaymentFailed, "tax_notification" => NotificationType::TaxNotification, "unknown" => NotificationType::Unknown, _ => NotificationType::Unknown, } } } #[derive(Serialize, Deserialize, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum NotificationBody { ProjectUpdate { project_id: ProjectId, version_id: VersionId, }, TeamInvite { project_id: ProjectId, team_id: TeamId, invited_by: UserId, role: String, }, OrganizationInvite { organization_id: OrganizationId, invited_by: UserId, team_id: TeamId, role: String, }, StatusChange { project_id: ProjectId, old_status: ProjectStatus, new_status: ProjectStatus, }, /// This is for website notifications only. Email notifications have `ModerationMessageReceived`. ModeratorMessage { thread_id: ThreadId, message_id: ThreadMessageId, project_id: Option, report_id: Option, }, PatCreated { token_name: String, }, /// This differs from ModeratorMessage as this notification is only for project threads and /// email notifications, not for site notifications. ModerationMessageReceived { project_id: ProjectId, }, ReportStatusUpdated { report_id: ReportId, }, ReportSubmitted { report_id: ReportId, }, ProjectStatusApproved { project_id: ProjectId, }, ProjectStatusNeutral { project_id: ProjectId, old_status: ProjectStatus, new_status: ProjectStatus, }, ProjectTransferred { project_id: ProjectId, new_owner_user_id: Option, new_owner_organization_id: Option, }, LegacyMarkdown { notification_type: Option, name: String, text: String, link: String, actions: Vec, }, ResetPassword { flow: String, }, VerifyEmail { flow: String, }, AuthProviderAdded { provider: String, }, AuthProviderRemoved { provider: String, }, TwoFactorEnabled, TwoFactorRemoved, PasswordChanged, PasswordRemoved, EmailChanged { new_email: String, to_email: String, }, PaymentFailed { amount: String, service: String, }, TaxNotification { subscription_id: UserSubscriptionId, new_amount: i64, new_tax_amount: i64, old_amount: i64, old_tax_amount: i64, billing_interval: PriceDuration, currency: String, due: DateTime, service: String, }, PayoutAvailable { date_available: DateTime, amount: f64, }, Unknown, } impl NotificationBody { pub fn notification_type(&self) -> NotificationType { match &self { NotificationBody::ProjectUpdate { .. } => { NotificationType::ProjectUpdate } NotificationBody::TeamInvite { .. } => NotificationType::TeamInvite, NotificationBody::OrganizationInvite { .. } => { NotificationType::OrganizationInvite } NotificationBody::StatusChange { .. } => { NotificationType::StatusChange } NotificationBody::ModeratorMessage { .. } => { NotificationType::ModeratorMessage } NotificationBody::PatCreated { .. } => NotificationType::PatCreated, NotificationBody::ModerationMessageReceived { .. } => { NotificationType::ModerationMessageReceived } NotificationBody::ReportStatusUpdated { .. } => { NotificationType::ReportStatusUpdated } NotificationBody::ReportSubmitted { .. } => { NotificationType::ReportSubmitted } NotificationBody::ProjectStatusApproved { .. } => { NotificationType::ProjectStatusApproved } NotificationBody::ProjectStatusNeutral { .. } => { NotificationType::ProjectStatusNeutral } NotificationBody::ProjectTransferred { .. } => { NotificationType::ProjectTransferred } NotificationBody::LegacyMarkdown { .. } => { NotificationType::LegacyMarkdown } NotificationBody::ResetPassword { .. } => { NotificationType::ResetPassword } NotificationBody::VerifyEmail { .. } => { NotificationType::VerifyEmail } NotificationBody::AuthProviderAdded { .. } => { NotificationType::AuthProviderAdded } NotificationBody::AuthProviderRemoved { .. } => { NotificationType::AuthProviderRemoved } NotificationBody::TwoFactorEnabled => { NotificationType::TwoFactorEnabled } NotificationBody::TwoFactorRemoved => { NotificationType::TwoFactorRemoved } NotificationBody::PasswordChanged => { NotificationType::PasswordChanged } NotificationBody::PasswordRemoved => { NotificationType::PasswordRemoved } NotificationBody::EmailChanged { .. } => { NotificationType::EmailChanged } NotificationBody::PaymentFailed { .. } => { NotificationType::PaymentFailed } NotificationBody::TaxNotification { .. } => { NotificationType::TaxNotification } NotificationBody::PayoutAvailable { .. } => { NotificationType::PayoutAvailable } NotificationBody::Unknown => NotificationType::Unknown, } } } impl From for Notification { fn from(notif: DBNotification) -> Self { let (name, text, link, actions) = { match ¬if.body { NotificationBody::ProjectUpdate { project_id, version_id, } => ( "A project you follow has been updated!".to_string(), format!( "The project {project_id} has released a new version: {version_id}" ), format!("/project/{project_id}/version/{version_id}"), vec![], ), NotificationBody::TeamInvite { project_id, role, team_id, .. } => ( "You have been invited to join a team!".to_string(), format!( "An invite has been sent for you to be {role} of a team" ), format!("/project/{project_id}"), vec![ NotificationAction { name: "Accept".to_string(), action_route: ( "POST".to_string(), format!("team/{team_id}/join"), ), }, NotificationAction { name: "Deny".to_string(), action_route: ( "DELETE".to_string(), format!( "team/{team_id}/members/{}", UserId::from(notif.user_id) ), ), }, ], ), NotificationBody::OrganizationInvite { organization_id, role, team_id, .. } => ( "You have been invited to join an organization!" .to_string(), format!( "An invite has been sent for you to be {role} of an organization" ), format!("/organization/{organization_id}"), vec![ NotificationAction { name: "Accept".to_string(), action_route: ( "POST".to_string(), format!("team/{team_id}/join"), ), }, NotificationAction { name: "Deny".to_string(), action_route: ( "DELETE".to_string(), format!( "organization/{organization_id}/members/{}", UserId::from(notif.user_id) ), ), }, ], ), NotificationBody::StatusChange { old_status, new_status, project_id, } => ( "Project status has changed".to_string(), format!( "Status has changed from {} to {}", old_status.as_friendly_str(), new_status.as_friendly_str() ), format!("/project/{project_id}"), vec![], ), NotificationBody::ModeratorMessage { project_id, report_id, .. } => ( "A moderator has sent you a message!".to_string(), "Click on the link to read more.".to_string(), if let Some(project_id) = project_id { format!("/project/{project_id}") } else if let Some(report_id) = report_id { format!("/project/{report_id}") } else { "#".to_string() }, vec![], ), // The notifications from here to down below are listed with messages for completeness' sake, // though they should never be sent via site notifications. This should be disabled via database // options. Messages should be reviewed and worded better if we want to distribute these notifications // via the site. NotificationBody::PatCreated { token_name } => ( "New personal access token created".to_string(), format!("Your personal access token '{token_name}' was created."), "#".to_string(), vec![], ), NotificationBody::ReportStatusUpdated { .. } => ( "Report status updated".to_string(), "A report you are involved in has been updated.".to_string(), "#".to_string(), vec![], ), NotificationBody::ReportSubmitted { .. } => ( "Report submitted".to_string(), "Your report was submitted successfully.".to_string(), "#".to_string(), vec![], ), NotificationBody::ProjectStatusApproved { .. } => ( "Project approved".to_string(), "Your project has been approved.".to_string(), "#".to_string(), vec![], ), NotificationBody::ProjectStatusNeutral { .. } => ( "Project status updated".to_string(), "Your project status has been updated.".to_string(), "#".to_string(), vec![], ), NotificationBody::ProjectTransferred { .. } => ( "Project ownership transferred".to_string(), "A project's ownership has been transferred.".to_string(), "#".to_string(), vec![], ), // Don't expose the `flow` field NotificationBody::ResetPassword { .. } => ( "Password reset requested".to_string(), "You've requested to reset your password. Please check your email for a reset link.".to_string(), "#".to_string(), vec![], ), NotificationBody::LegacyMarkdown { name, text, link, actions, .. } => ( name.clone(), text.clone(), link.clone(), actions.clone().into_iter().collect(), ), NotificationBody::PaymentFailed { .. } => ( "Payment failed".to_string(), "A payment on your account failed. Please update your billing information.".to_string(), "/settings/billing".to_string(), vec![], ), NotificationBody::VerifyEmail { .. } => ( "Verify your email".to_string(), "You've requested to verify your email. Please check your email for a verification link.".to_string(), "#".to_string(), vec![], ), NotificationBody::AuthProviderAdded { .. } => ( "Auth provider added".to_string(), "You've added a new authentication provider to your account.".to_string(), "#".to_string(), vec![], ), NotificationBody::AuthProviderRemoved { .. } => ( "Auth provider removed".to_string(), "You've removed a authentication provider from your account.".to_string(), "#".to_string(), vec![], ), NotificationBody::TwoFactorEnabled => ( "Two-factor authentication enabled".to_string(), "You've enabled two-factor authentication on your account.".to_string(), "#".to_string(), vec![], ), NotificationBody::TwoFactorRemoved => ( "Two-factor authentication removed".to_string(), "You've removed two-factor authentication from your account.".to_string(), "#".to_string(), vec![], ), NotificationBody::PasswordChanged => ( "Password changed".to_string(), "You've changed your account password.".to_string(), "#".to_string(), vec![], ), NotificationBody::PasswordRemoved => ( "Password removed".to_string(), "You've removed your account password.".to_string(), "#".to_string(), vec![], ), NotificationBody::EmailChanged { .. } => ( "Email changed".to_string(), "Your account email was changed.".to_string(), "#".to_string(), vec![], ), NotificationBody::TaxNotification { .. } => ( "Tax notification".to_string(), "You've received a tax notification.".to_string(), "#".to_string(), vec![], ), NotificationBody::PayoutAvailable { .. } => ( "Payout available".to_string(), "A payout is available!".to_string(), "#".to_string(), vec![], ), NotificationBody::ModerationMessageReceived { .. } => ( "New message in moderation thread".to_string(), "You have a new message in a moderation thread.".to_string(), "#".to_string(), vec![], ), NotificationBody::Unknown => { ("".to_string(), "".to_string(), "#".to_string(), vec![]) } } }; Self { id: notif.id.into(), user_id: notif.user_id.into(), body: notif.body, read: notif.read, created: notif.created, name, text, link, actions, } } } #[derive(Serialize, Deserialize, Clone)] pub struct NotificationAction { pub name: String, /// The route to call when this notification action is called. Formatted HTTP Method, route pub action_route: (String, String), } impl From for NotificationAction { fn from(act: DBNotificationAction) -> Self { Self { name: act.name, action_route: (act.action_route_method, act.action_route), } } } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NotificationChannel { Email, } impl NotificationChannel { pub fn list() -> &'static [Self] { &[NotificationChannel::Email] } pub fn as_str(self) -> &'static str { match self { NotificationChannel::Email => "email", } } pub fn from_str_or_default(s: &str) -> Self { match s { "email" => NotificationChannel::Email, _ => NotificationChannel::Email, } } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum NotificationDeliveryStatus { Pending, SkippedPreferences, SkippedDefault, Delivered, PermanentlyFailed, } impl NotificationDeliveryStatus { pub fn as_user_error(self) -> Result<(), ApiError> { match self { NotificationDeliveryStatus::Delivered => Ok(()), NotificationDeliveryStatus::SkippedPreferences | NotificationDeliveryStatus::SkippedDefault | NotificationDeliveryStatus::Pending => Err(ApiError::InvalidInput("An error occured while sending an email to your email address. Please try again later.".to_owned())), NotificationDeliveryStatus::PermanentlyFailed => Err(ApiError::InvalidInput("This email address doesn't exist! Please try another one.".to_owned())), } } pub fn as_str(self) -> &'static str { match self { NotificationDeliveryStatus::Pending => "pending", NotificationDeliveryStatus::SkippedPreferences => { "skipped_preferences" } NotificationDeliveryStatus::SkippedDefault => "skipped_default", NotificationDeliveryStatus::Delivered => "delivered", NotificationDeliveryStatus::PermanentlyFailed => { "permanently_failed" } } } pub fn from_str_or_default(s: &str) -> Self { match s { "pending" => NotificationDeliveryStatus::Pending, "skipped_preferences" => { NotificationDeliveryStatus::SkippedPreferences } "skipped_default" => NotificationDeliveryStatus::SkippedDefault, "delivered" => NotificationDeliveryStatus::Delivered, "permanently_failed" => { NotificationDeliveryStatus::PermanentlyFailed } _ => NotificationDeliveryStatus::Pending, } } } #[derive(Serialize, Deserialize)] pub struct NotificationDelivery { pub notification_id: NotificationId, pub user_id: UserId, pub channel: NotificationChannel, pub delivery_priority: i32, pub status: NotificationDeliveryStatus, pub next_attempt: DateTime, pub attempt_count: i32, } impl From for NotificationDelivery { fn from(delivery: DBNotificationDelivery) -> Self { Self { notification_id: delivery.notification_id.into(), user_id: delivery.user_id.into(), channel: delivery.channel, delivery_priority: delivery.delivery_priority, status: delivery.status, next_attempt: delivery.next_attempt, attempt_count: delivery.attempt_count, } } }