New Creator Notifications (#4383)

* Some new notification types

* Fix error

* Use existing DB models rather than inline queries

* Fix template fillout

* Fix ModerationThreadMessageReceived

* Insert more notifications, fix some formatting

* chore: query cache, clippy, fmt

* chore: query cache, clippy, fmt

* Use outer transactions to insert notifications instead of creating a new one

* Join futures
This commit is contained in:
François-Xavier Talbot
2025-09-17 15:37:21 -04:00
committed by GitHub
parent 8149618187
commit 6da190ed01
25 changed files with 1211 additions and 77 deletions

View File

@@ -1,7 +1,8 @@
use crate::database::redis::RedisPool;
use crate::queue::email::EmailQueue;
use crate::queue::payouts::{
PayoutsQueue, insert_bank_balances_and_webhook, process_payout,
PayoutsQueue, index_payouts_notifications,
insert_bank_balances_and_webhook, process_payout,
};
use crate::search::indexing::index_projects;
use crate::{database, search};
@@ -38,7 +39,7 @@ impl BackgroundTask {
IndexSearch => index_search(pool, redis_pool, search_config).await,
ReleaseScheduled => release_scheduled(pool).await,
UpdateVersions => update_versions(pool, redis_pool).await,
Payouts => payouts(pool, clickhouse).await,
Payouts => payouts(pool, clickhouse, redis_pool).await,
IndexBilling => {
crate::routes::internal::billing::index_billing(
stripe_client,
@@ -147,12 +148,19 @@ pub async fn update_versions(
pub async fn payouts(
pool: sqlx::Pool<Postgres>,
clickhouse: clickhouse::Client,
redis_pool: RedisPool,
) {
info!("Started running payouts");
let result = process_payout(&pool, &clickhouse).await;
if let Err(e) = result {
warn!("Payouts run failed: {:?}", e);
}
let result = index_payouts_notifications(&pool, &redis_pool).await;
if let Err(e) = result {
warn!("Payouts notifications indexing failed: {:?}", e);
}
info!("Done running payouts");
}

View File

@@ -60,12 +60,15 @@ macro_rules! generate_bulk_ids {
count: usize,
con: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Vec<$return_type>, DatabaseError> {
let mut rng = rand::thread_rng();
let mut retry_count = 0;
// Check if ID is unique
loop {
let base = random_base62_rng_range(&mut rng, 1, 10) as i64;
// We re-acquire a thread-local RNG handle for each uniqueness loop for
// the bulk generator future to be `Send + Sync`.
let base =
random_base62_rng_range(&mut rand::thread_rng(), 1, 10)
as i64;
let ids =
(0..count).map(|x| base + x as i64).collect::<Vec<_>>();

View File

@@ -19,6 +19,7 @@ pub mod oauth_token_item;
pub mod organization_item;
pub mod pat_item;
pub mod payout_item;
pub mod payouts_values_notifications;
pub mod product_item;
pub mod project_item;
pub mod report_item;

View File

@@ -2,6 +2,7 @@ use super::ids::*;
use crate::database::{models::DatabaseError, redis::RedisPool};
use crate::models::notifications::{
NotificationBody, NotificationChannel, NotificationDeliveryStatus,
NotificationType,
};
use chrono::{DateTime, Utc};
use futures::TryStreamExt;
@@ -41,6 +42,71 @@ impl NotificationBuilder {
self.insert_many(vec![user], transaction, redis).await
}
pub async fn insert_many_payout_notifications(
users: Vec<DBUserId>,
dates_available: Vec<DateTime<Utc>>,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
redis: &RedisPool,
) -> Result<(), DatabaseError> {
let notification_ids =
generate_many_notification_ids(users.len(), &mut *transaction)
.await?;
let users_raw_ids = users.iter().map(|x| x.0).collect::<Vec<_>>();
let notification_ids =
notification_ids.iter().map(|x| x.0).collect::<Vec<_>>();
sqlx::query!(
"
WITH
period_payouts AS (
SELECT
ids.notification_id,
ids.user_id,
ids.date_available,
COALESCE(SUM(pv.amount), 0.0) sum
FROM UNNEST($1::bigint[], $2::bigint[], $3::timestamptz[]) AS ids(notification_id, user_id, date_available)
LEFT JOIN payouts_values pv ON pv.user_id = ids.user_id AND pv.date_available = ids.date_available
GROUP BY ids.user_id, ids.notification_id, ids.date_available
)
INSERT INTO notifications (
id, user_id, body
)
SELECT
notification_id id,
user_id,
JSONB_BUILD_OBJECT(
'type', 'payout_available',
'date_available', to_jsonb(date_available),
'amount', to_jsonb(sum)
) body
FROM period_payouts
",
&notification_ids[..],
&users_raw_ids[..],
&dates_available[..],
)
.execute(&mut **transaction)
.await?;
let notification_types = notification_ids
.iter()
.map(|_| NotificationType::PayoutAvailable.as_str())
.collect::<Vec<_>>();
NotificationBuilder::insert_many_deliveries(
transaction,
redis,
&notification_ids,
&users_raw_ids,
&notification_types,
&users,
)
.await?;
Ok(())
}
pub async fn insert_many(
&self,
users: Vec<DBUserId>,
@@ -80,6 +146,27 @@ impl NotificationBuilder {
.map(|_| self.body.notification_type().as_str())
.collect::<Vec<_>>();
NotificationBuilder::insert_many_deliveries(
transaction,
redis,
&notification_ids,
&users_raw_ids,
&notification_types,
&users,
)
.await?;
Ok(())
}
async fn insert_many_deliveries(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
redis: &RedisPool,
notification_ids: &[i64],
users_raw_ids: &[i64],
notification_types: &[&str],
users: &[DBUserId],
) -> Result<(), DatabaseError> {
let notification_channels = NotificationChannel::list()
.iter()
.map(|x| x.as_str())
@@ -159,7 +246,7 @@ impl NotificationBuilder {
query.execute(&mut **transaction).await?;
DBNotification::clear_user_notifications_cache(&users, redis).await?;
DBNotification::clear_user_notifications_cache(users, redis).await?;
Ok(())
}

View File

@@ -0,0 +1,89 @@
use crate::database::models::{DBUserId, DatabaseError};
use chrono::{DateTime, Utc};
pub struct PayoutsValuesNotification {
pub id: i32,
pub user_id: DBUserId,
pub date_available: DateTime<Utc>,
}
impl PayoutsValuesNotification {
pub async fn unnotified_users_with_available_payouts_with_limit(
exec: impl sqlx::PgExecutor<'_>,
limit: i64,
) -> Result<Vec<PayoutsValuesNotification>, DatabaseError> {
Ok(sqlx::query_as!(
QueryResult,
"
SELECT
id,
user_id,
date_available
FROM payouts_values_notifications
WHERE
notified = FALSE
AND date_available <= NOW()
FOR UPDATE SKIP LOCKED
LIMIT $1
",
limit,
)
.fetch_all(exec)
.await?
.into_iter()
.map(Into::into)
.collect())
}
pub async fn set_notified_many(
ids: &[i32],
exec: impl sqlx::PgExecutor<'_>,
) -> Result<(), DatabaseError> {
sqlx::query!(
"
UPDATE payouts_values_notifications
SET notified = TRUE
WHERE id = ANY($1)
",
&ids[..],
)
.execute(exec)
.await?;
Ok(())
}
}
pub async fn synchronize_future_payout_values(
exec: impl sqlx::PgExecutor<'_>,
) -> Result<(), DatabaseError> {
sqlx::query!(
"
INSERT INTO payouts_values_notifications (date_available, user_id, notified)
SELECT DISTINCT date_available, user_id, false notified
FROM payouts_values
WHERE date_available > NOW()
ON CONFLICT (date_available, user_id) DO NOTHING
",
)
.execute(exec)
.await?;
Ok(())
}
struct QueryResult {
id: i32,
user_id: i64,
date_available: DateTime<Utc>,
}
impl From<QueryResult> for PayoutsValuesNotification {
fn from(result: QueryResult) -> Self {
PayoutsValuesNotification {
id: result.id,
user_id: DBUserId(result.user_id),
date_available: result.date_available,
}
}
}

View File

@@ -148,11 +148,13 @@ pub fn app_setup(
let pool_ref = pool.clone();
let client_ref = clickhouse.clone();
let redis_pool_ref = redis_pool.clone();
scheduler.run(Duration::from_secs(60 * 60 * 6), move || {
let pool_ref = pool_ref.clone();
let client_ref = client_ref.clone();
let redis_ref = redis_pool_ref.clone();
async move {
background_task::payouts(pool_ref, client_ref).await;
background_task::payouts(pool_ref, client_ref, redis_ref).await;
}
});

View File

@@ -96,6 +96,36 @@ pub enum LegacyNotificationBody {
amount: String,
service: String,
},
PatCreated {
token_name: String,
},
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,
// Store only the raw identifiers in legacy body
new_owner_user_id: Option<UserId>,
new_owner_organization_id: Option<OrganizationId>,
},
PayoutAvailable {
amount: f64,
date_available: DateTime<Utc>,
},
Unknown,
}
@@ -117,6 +147,27 @@ impl LegacyNotification {
NotificationBody::ModeratorMessage { .. } => {
Some("moderator_message".to_string())
}
NotificationBody::PatCreated { .. } => {
Some("pat_created".to_string())
}
NotificationBody::ModerationMessageReceived { .. } => {
Some("moderation_message_received".to_string())
}
NotificationBody::ReportStatusUpdated { .. } => {
Some("report_status_updated".to_string())
}
NotificationBody::ReportSubmitted { .. } => {
Some("report_submitted".to_string())
}
NotificationBody::ProjectStatusApproved { .. } => {
Some("project_status_approved".to_string())
}
NotificationBody::ProjectStatusNeutral { .. } => {
Some("project_status_neutral".to_string())
}
NotificationBody::ProjectTransferred { .. } => {
Some("project_transferred".to_string())
}
NotificationBody::ResetPassword { .. } => {
Some("reset_password".to_string())
}
@@ -147,6 +198,9 @@ impl LegacyNotification {
NotificationBody::PaymentFailed { .. } => {
Some("payment_failed".to_string())
}
NotificationBody::PayoutAvailable { .. } => {
Some("payout_available".to_string())
}
NotificationBody::LegacyMarkdown {
notification_type, ..
} => notification_type.clone(),
@@ -203,6 +257,46 @@ impl LegacyNotification {
project_id,
report_id,
},
NotificationBody::PatCreated { token_name } => {
LegacyNotificationBody::PatCreated { token_name }
}
NotificationBody::ModerationMessageReceived { project_id } => {
LegacyNotificationBody::ModerationMessageReceived { project_id }
}
NotificationBody::ReportStatusUpdated { report_id } => {
LegacyNotificationBody::ReportStatusUpdated { report_id }
}
NotificationBody::ReportSubmitted { report_id } => {
LegacyNotificationBody::ReportSubmitted { report_id }
}
NotificationBody::ProjectStatusApproved { project_id } => {
LegacyNotificationBody::ProjectStatusApproved { project_id }
}
NotificationBody::ProjectStatusNeutral {
project_id,
old_status,
new_status,
} => LegacyNotificationBody::ProjectStatusNeutral {
project_id,
old_status,
new_status,
},
NotificationBody::ProjectTransferred {
project_id,
new_owner_user_id,
new_owner_organization_id,
} => LegacyNotificationBody::ProjectTransferred {
project_id,
new_owner_user_id,
new_owner_organization_id,
},
NotificationBody::PayoutAvailable {
amount,
date_available,
} => LegacyNotificationBody::PayoutAvailable {
amount,
date_available,
},
NotificationBody::LegacyMarkdown {
notification_type,
name,

View File

@@ -46,6 +46,14 @@ pub enum NotificationType {
PasswordRemoved,
EmailChanged,
PaymentFailed,
PatCreated,
ModerationMessageReceived,
ReportStatusUpdated,
ReportSubmitted,
ProjectStatusApproved,
ProjectStatusNeutral,
ProjectTransferred,
PayoutAvailable,
Unknown,
}
@@ -68,6 +76,18 @@ impl NotificationType {
NotificationType::PasswordRemoved => "password_removed",
NotificationType::EmailChanged => "email_changed",
NotificationType::PaymentFailed => "payment_failed",
NotificationType::PatCreated => "pat_created",
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::PayoutAvailable => "payout_available",
NotificationType::Unknown => "unknown",
}
}
@@ -90,6 +110,18 @@ impl NotificationType {
"password_removed" => NotificationType::PasswordRemoved,
"email_changed" => NotificationType::EmailChanged,
"payment_failed" => NotificationType::PaymentFailed,
"pat_created" => NotificationType::PatCreated,
"moderation_message_received" => {
NotificationType::ModerationMessageReceived
}
"report_status_updated" => NotificationType::ReportStatusUpdated,
"report_submitted" => NotificationType::ReportSubmitted,
"project_status_approved" => {
NotificationType::ProjectStatusApproved
}
"project_status_neutral" => NotificationType::ProjectStatusNeutral,
"project_transferred" => NotificationType::ProjectTransferred,
"payout_available" => NotificationType::PayoutAvailable,
"unknown" => NotificationType::Unknown,
_ => NotificationType::Unknown,
}
@@ -120,6 +152,7 @@ pub enum NotificationBody {
old_status: ProjectStatus,
new_status: ProjectStatus,
},
/// This is for website notifications only. Email notifications have `ModerationMessageReceived`.
ModeratorMessage {
thread_id: ThreadId,
message_id: ThreadMessageId,
@@ -127,6 +160,33 @@ pub enum NotificationBody {
project_id: Option<ProjectId>,
report_id: Option<ReportId>,
},
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<UserId>,
new_owner_organization_id: Option<OrganizationId>,
},
LegacyMarkdown {
notification_type: Option<String>,
name: String,
@@ -158,6 +218,10 @@ pub enum NotificationBody {
amount: String,
service: String,
},
PayoutAvailable {
date_available: DateTime<Utc>,
amount: f64,
},
Unknown,
}
@@ -177,6 +241,25 @@ impl NotificationBody {
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
}
@@ -210,6 +293,9 @@ impl NotificationBody {
NotificationBody::PaymentFailed { .. } => {
NotificationType::PaymentFailed
}
NotificationBody::PayoutAvailable { .. } => {
NotificationType::PayoutAvailable
}
NotificationBody::Unknown => NotificationType::Unknown,
}
}
@@ -323,6 +409,46 @@ impl From<DBNotification> for Notification {
},
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(),
@@ -342,10 +468,6 @@ impl From<DBNotification> for Notification {
link.clone(),
actions.clone().into_iter().collect(),
),
// 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::PaymentFailed { .. } => (
"Payment failed".to_string(),
"A payment on your account failed. Please update your billing information.".to_string(),
@@ -400,6 +522,18 @@ impl From<DBNotification> for Notification {
"#".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![])
}

View File

@@ -307,7 +307,7 @@ impl EmailQueue {
};
let message = templates::build_email(
&mut **txn,
txn,
&self.redis,
&self.client,
user_id,

View File

@@ -1,11 +1,13 @@
use super::MailError;
use crate::database::models::DBUser;
use crate::database::models::DatabaseError;
use crate::database::models::ids::*;
use crate::database::models::notifications_template_item::NotificationTemplate;
use crate::database::models::{
DBOrganization, DBProject, DBUser, DatabaseError,
};
use crate::database::redis::RedisPool;
use crate::models::v3::notifications::NotificationBody;
use crate::routes::ApiError;
use ariadne::ids::base62_impl::to_base62;
use futures::TryFutureExt;
use lettre::Message;
use lettre::message::{Mailbox, MultiPart, SinglePart};
@@ -38,6 +40,27 @@ const STATUSCHANGE_PROJECT_NAME: &str = "statuschange.project.name";
const STATUSCHANGE_OLD_STATUS: &str = "statuschange.old.status";
const STATUSCHANGE_NEW_STATUS: &str = "statuschange.new.status";
const NEWPAT_TOKEN_NAME: &str = "newpat.token_name";
const PROJECT_ID: &str = "project.id";
const PROJECT_NAME: &str = "project.name";
const PROJECT_ICON_URL: &str = "project.icon_url";
const REPORT_ID: &str = "report.id";
const REPORT_TITLE: &str = "report.title";
const REPORT_DATE: &str = "report.date";
const NEWREPORT_ID: &str = "newreport.id";
const PROJECT_OLD_STATUS: &str = "project.oldstatus";
const PROJECT_NEW_STATUS: &str = "project.newstatus";
const NEWOWNER_TYPE: &str = "new_owner.type";
const NEWOWNER_TYPE_CAPITALIZED: &str = "new_owner.type_capitalized";
const NEWOWNER_NAME: &str = "new_owner.name";
const PAYOUTAVAILABLE_AMOUNT: &str = "payout.amount";
const PAYOUTAVAILABLE_PERIOD: &str = "payout.period";
#[derive(Clone)]
pub struct MailingIdentity {
from_name: String,
@@ -59,7 +82,7 @@ impl MailingIdentity {
#[allow(clippy::too_many_arguments)]
pub async fn build_email(
exec: impl sqlx::PgExecutor<'_>,
exec: &mut sqlx::PgTransaction<'_>,
redis: &RedisPool,
client: &reqwest::Client,
user_id: DBUserId,
@@ -181,27 +204,177 @@ fn fill_template(
}
async fn collect_template_variables(
exec: impl sqlx::PgExecutor<'_>,
exec: &mut sqlx::PgTransaction<'_>,
redis: &RedisPool,
user_id: DBUserId,
n: &NotificationBody,
) -> Result<HashMap<&'static str, String>, ApiError> {
async fn only_select_default_variables(
exec: impl sqlx::PgExecutor<'_>,
redis: &RedisPool,
user_id: DBUserId,
) -> Result<HashMap<&'static str, String>, ApiError> {
let mut map = HashMap::new();
let db_user = DBUser::get_id(user_id, &mut **exec, redis)
.await?
.ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?;
let user = DBUser::get_id(user_id, exec, redis)
.await?
.ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?;
map.insert(USER_NAME, user.username);
Ok(map)
}
let mut map = HashMap::new();
map.insert(USER_NAME, db_user.username);
match &n {
NotificationBody::PatCreated { token_name } => {
map.insert(NEWPAT_TOKEN_NAME, token_name.clone());
Ok(map)
}
NotificationBody::ModerationMessageReceived { project_id, .. } => {
let result = DBProject::get_id(
DBProjectId(project_id.0 as i64),
exec,
redis,
)
.await?
.ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?
.inner;
map.insert(PROJECT_ID, to_base62(project_id.0));
map.insert(PROJECT_NAME, result.name);
map.insert(PROJECT_ICON_URL, result.icon_url.unwrap_or_default());
Ok(map)
}
NotificationBody::ReportStatusUpdated { report_id } => {
let result = query!(
r#"
SELECT
r.created,
COALESCE(m.name, v.version_number, u.username, 'unknown') "title!"
FROM reports r
LEFT JOIN mods m ON r.mod_id = m.id
LEFT JOIN versions v ON r.version_id = v.id
LEFT JOIN users u ON r.user_id = u.id
WHERE r.id = $1
"#,
report_id.0 as i64
)
.fetch_one(&mut **exec)
.await?;
map.insert(REPORT_ID, to_base62(report_id.0));
map.insert(REPORT_TITLE, result.title);
map.insert(REPORT_DATE, date_human_readable(result.created));
Ok(map)
}
NotificationBody::ReportSubmitted { report_id } => {
let result = query!(
r#"
SELECT
COALESCE(m.name, v.version_number, u.username, 'unknown') "title!"
FROM reports r
LEFT JOIN mods m ON r.mod_id = m.id
LEFT JOIN versions v ON r.version_id = v.id
LEFT JOIN users u ON r.user_id = u.id
WHERE r.id = $1
"#,
report_id.0 as i64
)
.fetch_one(&mut **exec)
.await?;
map.insert(REPORT_TITLE, result.title);
map.insert(NEWREPORT_ID, to_base62(report_id.0));
Ok(map)
}
NotificationBody::ProjectStatusApproved { project_id } => {
let result = query!(
r#"
SELECT name, icon_url FROM mods WHERE id = $1
"#,
project_id.0 as i64
)
.fetch_one(&mut **exec)
.await?;
map.insert(PROJECT_ID, to_base62(project_id.0));
map.insert(PROJECT_NAME, result.name);
map.insert(PROJECT_ICON_URL, result.icon_url.unwrap_or_default());
Ok(map)
}
NotificationBody::ProjectStatusNeutral {
project_id,
old_status,
new_status,
} => {
let result = DBProject::get_id(
DBProjectId(project_id.0 as i64),
exec,
redis,
)
.await?
.ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?
.inner;
map.insert(PROJECT_ID, to_base62(project_id.0));
map.insert(PROJECT_NAME, result.name);
map.insert(PROJECT_ICON_URL, result.icon_url.unwrap_or_default());
map.insert(PROJECT_OLD_STATUS, old_status.as_str().to_string());
map.insert(PROJECT_NEW_STATUS, new_status.as_str().to_string());
Ok(map)
}
NotificationBody::ProjectTransferred {
project_id,
new_owner_user_id,
new_owner_organization_id,
} => {
let project = DBProject::get_id(
DBProjectId(project_id.0 as i64),
&mut **exec,
redis,
)
.await?
.ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?
.inner;
map.insert(PROJECT_ID, to_base62(project_id.0));
map.insert(PROJECT_NAME, project.name);
map.insert(PROJECT_ICON_URL, project.icon_url.unwrap_or_default());
if let Some(new_owner_user_id) = new_owner_user_id {
let user = DBUser::get_id(
DBUserId(new_owner_user_id.0 as i64),
&mut **exec,
redis,
)
.await?
.ok_or_else(|| {
DatabaseError::Database(sqlx::Error::RowNotFound)
})?;
map.insert(NEWOWNER_TYPE, "user".to_string());
map.insert(NEWOWNER_TYPE_CAPITALIZED, "User".to_string());
map.insert(NEWOWNER_NAME, user.username);
} else if let Some(new_owner_organization_id) =
new_owner_organization_id
{
let org = DBOrganization::get_id(
DBOrganizationId(new_owner_organization_id.0 as i64),
&mut **exec,
redis,
)
.await?
.ok_or_else(|| {
DatabaseError::Database(sqlx::Error::RowNotFound)
})?;
map.insert(NEWOWNER_TYPE, "organization".to_string());
map.insert(
NEWOWNER_TYPE_CAPITALIZED,
"Organization".to_string(),
);
map.insert(NEWOWNER_NAME, org.name);
}
Ok(map)
}
NotificationBody::TeamInvite {
team_id: _,
project_id,
@@ -224,11 +397,9 @@ async fn collect_template_variables(
project_id.0 as i64,
user_id.0 as i64
)
.fetch_one(exec)
.fetch_one(&mut **exec)
.await?;
let mut map = HashMap::new();
map.insert(USER_NAME, result.user_name);
map.insert(TEAMINVITE_INVITER_NAME, result.inviter_name);
map.insert(TEAMINVITE_PROJECT_NAME, result.project_name);
map.insert(TEAMINVITE_ROLE_NAME, role.clone());
@@ -258,11 +429,9 @@ async fn collect_template_variables(
organization_id.0 as i64,
user_id.0 as i64
)
.fetch_one(exec)
.fetch_one(&mut **exec)
.await?;
let mut map = HashMap::new();
map.insert(USER_NAME, result.user_name);
map.insert(ORGINVITE_INVITER_NAME, result.inviter_name);
map.insert(ORGINVITE_ORG_NAME, result.organization_name);
map.insert(ORGINVITE_ROLE_NAME, role.clone());
@@ -288,11 +457,9 @@ async fn collect_template_variables(
project_id.0 as i64,
user_id.0 as i64,
)
.fetch_one(exec)
.fetch_one(&mut **exec)
.await?;
let mut map = HashMap::new();
map.insert(USER_NAME, result.user_name);
map.insert(STATUSCHANGE_PROJECT_NAME, result.project_name);
map.insert(STATUSCHANGE_OLD_STATUS, old_status.as_str().to_owned());
map.insert(STATUSCHANGE_NEW_STATUS, new_status.as_str().to_owned());
@@ -308,13 +475,7 @@ async fn collect_template_variables(
flow
);
let user = DBUser::get_id(user_id, exec, redis).await?.ok_or_else(
|| DatabaseError::Database(sqlx::Error::RowNotFound),
)?;
let mut map = HashMap::new();
map.insert(RESETPASSWORD_URL, url);
map.insert(USER_NAME, user.username);
Ok(map)
}
@@ -327,25 +488,13 @@ async fn collect_template_variables(
flow
);
let user = DBUser::get_id(user_id, exec, redis).await?.ok_or_else(
|| DatabaseError::Database(sqlx::Error::RowNotFound),
)?;
let mut map = HashMap::new();
map.insert(VERIFYEMAIL_URL, url);
map.insert(USER_NAME, user.username);
Ok(map)
}
NotificationBody::AuthProviderAdded { provider }
| NotificationBody::AuthProviderRemoved { provider } => {
let user = DBUser::get_id(user_id, exec, redis).await?.ok_or_else(
|| DatabaseError::Database(sqlx::Error::RowNotFound),
)?;
let mut map = HashMap::new();
map.insert(USER_NAME, user.username);
map.insert(AUTHPROVIDER_NAME, provider.clone());
Ok(map)
@@ -354,30 +503,18 @@ async fn collect_template_variables(
NotificationBody::TwoFactorEnabled
| NotificationBody::TwoFactorRemoved
| NotificationBody::PasswordChanged
| NotificationBody::PasswordRemoved => {
only_select_default_variables(exec, redis, user_id).await
}
| NotificationBody::PasswordRemoved => Ok(map),
NotificationBody::EmailChanged {
new_email,
to_email: _,
} => {
let user = DBUser::get_id(user_id, exec, redis).await?.ok_or_else(
|| DatabaseError::Database(sqlx::Error::RowNotFound),
)?;
let mut map = HashMap::new();
map.insert(USER_NAME, user.username);
map.insert(EMAILCHANGED_NEW_EMAIL, new_email.clone());
Ok(map)
}
NotificationBody::PaymentFailed { amount, service } => {
let user = DBUser::get_id(user_id, exec, redis).await?.ok_or_else(
|| DatabaseError::Database(sqlx::Error::RowNotFound),
)?;
let url = format!(
"{}/{}",
dotenvy::var("SITE_URL")?,
@@ -385,7 +522,6 @@ async fn collect_template_variables(
);
let mut map = HashMap::new();
map.insert(USER_NAME, user.username);
map.insert(PAYMENTFAILED_AMOUNT, amount.clone());
map.insert(PAYMENTFAILED_SERVICE, service.clone());
map.insert(BILLING_URL, url);
@@ -393,11 +529,34 @@ async fn collect_template_variables(
Ok(map)
}
NotificationBody::ProjectUpdate { .. }
| NotificationBody::LegacyMarkdown { .. }
| NotificationBody::ModeratorMessage { .. }
| NotificationBody::Unknown => {
only_select_default_variables(exec, redis, user_id).await
NotificationBody::PayoutAvailable {
amount,
date_available,
} => {
if let Some(period_month) =
date_available.checked_sub_months(chrono::Months::new(2))
{
map.insert(
PAYOUTAVAILABLE_PERIOD,
period_month.format("%B %Y").to_string(),
);
}
map.insert(
PAYOUTAVAILABLE_AMOUNT,
format!("{:.2}", (amount * 100.0) as i64),
);
Ok(map)
}
NotificationBody::ProjectUpdate { .. }
| NotificationBody::ModeratorMessage { .. }
| NotificationBody::LegacyMarkdown { .. }
| NotificationBody::Unknown => Ok(map),
}
}
fn date_human_readable(date: chrono::DateTime<chrono::Utc>) -> String {
date.format("%B %d, %Y").to_string()
}

View File

@@ -711,7 +711,19 @@ impl AutomatedModerationQueue {
},
}
.insert_many(
members.into_iter().map(|x| x.user_id).collect(),
members.iter().map(|x| x.user_id).collect(),
&mut transaction,
&redis,
)
.await?;
NotificationBuilder {
body: NotificationBody::ModerationMessageReceived {
project_id: project.inner.id.into(),
},
}
.insert_many(
members.iter().map(|x| x.user_id).collect(),
&mut transaction,
&redis,
)

View File

@@ -1,3 +1,6 @@
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::payouts_values_notifications;
use crate::database::redis::RedisPool;
use crate::models::payouts::{
PayoutDecimal, PayoutInterval, PayoutMethod, PayoutMethodFee,
PayoutMethodType,
@@ -1084,6 +1087,41 @@ pub async fn insert_payouts(
.await
}
pub async fn index_payouts_notifications(
pool: &PgPool,
redis: &RedisPool,
) -> Result<(), ApiError> {
let mut transaction = pool.begin().await?;
payouts_values_notifications::synchronize_future_payout_values(
&mut *transaction,
)
.await?;
let items = payouts_values_notifications::PayoutsValuesNotification::unnotified_users_with_available_payouts_with_limit(&mut *transaction, 200).await?;
let payout_ref_ids = items.iter().map(|x| x.id).collect::<Vec<_>>();
let dates_available =
items.iter().map(|x| x.date_available).collect::<Vec<_>>();
let user_ids = items.iter().map(|x| x.user_id).collect::<Vec<_>>();
NotificationBuilder::insert_many_payout_notifications(
user_ids,
dates_available,
&mut transaction,
redis,
)
.await?;
payouts_values_notifications::PayoutsValuesNotification::set_notified_many(
&payout_ref_ids,
&mut *transaction,
)
.await?;
transaction.commit().await?;
Ok(())
}
pub async fn insert_bank_balances_and_webhook(
payouts: &PayoutsQueue,
pool: &PgPool,

View File

@@ -13,6 +13,8 @@ use rand::distributions::Alphanumeric;
use rand_chacha::ChaCha20Rng;
use rand_chacha::rand_core::SeedableRng;
use crate::database::models::notification_item::NotificationBuilder;
use crate::models::notifications::NotificationBody;
use crate::models::pats::{PersonalAccessToken, Scopes};
use crate::queue::session::AuthQueue;
use crate::util::validate::validation_errors_to_string;
@@ -129,7 +131,15 @@ pub async fn create_pat(
.insert(&mut transaction)
.await?;
NotificationBuilder {
body: NotificationBody::PatCreated {
token_name: name.clone(),
},
}
.insert(user.id.into(), &mut transaction, &redis)
.await?;
transaction.commit().await?;
database::models::pat_item::DBPersonalAccessToken::clear_cache(
vec![(None, None, Some(user.id.into()))],
&redis,

View File

@@ -476,6 +476,22 @@ pub async fn project_edit(
new_status: *status,
},
}
.insert_many(notified_members.clone(), &mut transaction, &redis)
.await?;
NotificationBuilder {
body: if status.is_approved() {
NotificationBody::ProjectStatusApproved {
project_id: project_item.inner.id.into(),
}
} else {
NotificationBody::ProjectStatusNeutral {
project_id: project_item.inner.id.into(),
old_status: project_item.inner.status,
new_status: *status,
}
},
}
.insert_many(notified_members, &mut transaction, &redis)
.await?;
}

View File

@@ -1,6 +1,7 @@
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::database;
use crate::database::models::image_item;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::thread_item::{
ThreadBuilder, ThreadMessageBuilder,
};
@@ -8,6 +9,7 @@ use crate::database::redis::RedisPool;
use crate::models::ids::ImageId;
use crate::models::ids::{ProjectId, VersionId};
use crate::models::images::{Image, ImageContext};
use crate::models::notifications::NotificationBody;
use crate::models::pats::Scopes;
use crate::models::reports::{ItemType, Report};
use crate::models::threads::{MessageBody, ThreadType};
@@ -204,6 +206,15 @@ pub async fn report_create(
.insert(&mut transaction)
.await?;
// Notify the reporter that the report has been submitted
NotificationBuilder {
body: NotificationBody::ReportSubmitted {
report_id: id.into(),
},
}
.insert(current_user.id.into(), &mut transaction, &redis)
.await?;
transaction.commit().await?;
Ok(HttpResponse::Ok().json(Report {
@@ -455,6 +466,14 @@ pub async fn report_edit(
.insert(&mut transaction)
.await?;
NotificationBuilder {
body: NotificationBody::ReportStatusUpdated {
report_id: id.into(),
},
}
.insert(report.reporter, &mut transaction, &redis)
.await?;
sqlx::query!(
"
UPDATE reports

View File

@@ -878,7 +878,7 @@ pub async fn transfer_ownership(
// Forbid transferring ownership of a project team that is owned by an organization
// These are owned by the organization owner, and must be removed from the organization first
// There shouldnt be an ownr on these projects in these cases, but just in case.
// There shouldnt be an owner on these projects in these cases, but just in case.
let team_association_id =
DBTeam::get_association(id.into(), &**pool).await?;
if let Some(TeamAssociationId::Project(pid)) = team_association_id {
@@ -1018,7 +1018,21 @@ pub async fn transfer_ownership(
vec![]
};
// If this team is associated with a project, notify the new owner
if let Some(TeamAssociationId::Project(pid)) = team_association_id {
NotificationBuilder {
body: NotificationBody::ProjectTransferred {
project_id: pid.into(),
new_owner_user_id: Some(new_owner.user_id),
new_owner_organization_id: None,
},
}
.insert(new_owner.user_id.into(), &mut transaction, &redis)
.await?;
}
transaction.commit().await?;
DBTeamMember::clear_cache(id.into(), &redis).await?;
for team_id in project_teams_edited {
DBTeamMember::clear_cache(team_id, &redis).await?;

View File

@@ -477,7 +477,19 @@ pub async fn thread_send_message(
},
}
.insert_many(
members.into_iter().map(|x| x.user_id).collect(),
members.iter().map(|x| x.user_id).collect(),
&mut transaction,
&redis,
)
.await?;
NotificationBuilder {
body: NotificationBody::ModerationMessageReceived {
project_id: project.inner.id.into(),
},
}
.insert_many(
members.iter().map(|x| x.user_id).collect(),
&mut transaction,
&redis,
)