diff --git a/Cargo.lock b/Cargo.lock index bb2ebf6..1d3133c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -758,6 +758,7 @@ dependencies = [ "diesel_derives", "itoa", "pq-sys", + "serde_json", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 3247283..30a8e40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ axum = { version = "0.8.1", features = ["macros"] } axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] } chrono = { version = "0.4.39", features = ["serde"] } base64 = "0.22.1" -diesel = { version = "2.2.6", features = ["postgres", "chrono", "uuid"] } +diesel = { version = "2.2.6", features = ["postgres", "chrono", "uuid", "serde_json"] } tower = "0.5.2" regex = "1.11.1" lettre = { version = "0.11.12", features = ["tokio1", "serde", "tracing", "tokio1-native-tls"] } diff --git a/migrations/2024-11-25-232658_init/down.sql b/migrations/2024-11-25-232658_init/down.sql index 9e1325b..f929c32 100644 --- a/migrations/2024-11-25-232658_init/down.sql +++ b/migrations/2024-11-25-232658_init/down.sql @@ -1,3 +1,5 @@ +DROP TABLE IF EXISTS channel_selections; +DROP TABLE IF EXISTS channels; DROP TABLE IF EXISTS projects; DROP TABLE IF EXISTS api_keys; DROP TABLE IF EXISTS team_memberships; diff --git a/migrations/2024-11-25-232658_init/up.sql b/migrations/2024-11-25-232658_init/up.sql index 6432d10..2706c40 100644 --- a/migrations/2024-11-25-232658_init/up.sql +++ b/migrations/2024-11-25-232658_init/up.sql @@ -41,3 +41,19 @@ CREATE TABLE projects ( UNIQUE (team_id, name) ); CREATE INDEX ON projects(team_id); + +CREATE TABLE IF NOT EXISTS channels ( + id UUID NOT NULL PRIMARY KEY, + team_id UUID NOT NULL REFERENCES teams(id), + name TEXT NOT NULL, + enable_by_default BOOLEAN NOT NULL DEFAULT FALSE, + backend_config JSONB NOT NULL DEFAULT '{}'::JSONB +); + +CREATE TABLE IF NOT EXISTS channel_selections ( + project_id UUID NOT NULL REFERENCES projects(id), + channel_id UUID NOT NULL REFERENCES channels(id), + PRIMARY KEY (project_id, channel_id) +); +CREATE INDEX ON channel_selections (project_id); +CREATE INDEX ON channel_selections (channel_id); diff --git a/migrations/2025-01-31-045014_messages/up.sql b/migrations/2025-01-31-045014_messages/up.sql index 902a1a3..95c628a 100644 --- a/migrations/2025-01-31-045014_messages/up.sql +++ b/migrations/2025-01-31-045014_messages/up.sql @@ -1,8 +1,10 @@ CREATE TABLE IF NOT EXISTS messages ( id UUID PRIMARY KEY NOT NULL, - project_id UUID NOT NULL REFERENCES projects (id), + channel_id UUID NOT NULL REFERENCES channels (id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sent_at TIMESTAMPTZ, message TEXT NOT NULL ); -CREATE INDEX ON messages (project_id); +CREATE INDEX ON messages (channel_id); CREATE INDEX ON messages (created_at); +CREATE INDEX ON messages (sent_at); diff --git a/migrations/2025-02-04-070208_init_channels/down.sql b/migrations/2025-02-04-070208_init_channels/down.sql deleted file mode 100644 index 58f5574..0000000 --- a/migrations/2025-02-04-070208_init_channels/down.sql +++ /dev/null @@ -1,4 +0,0 @@ -DROP TABLE IF EXISTS channel_selections; -DROP TABLE IF EXISTS slack_channels; -DROP TABLE IF EXISTS email_channels; -DROP TABLE IF EXISTS channels; diff --git a/migrations/2025-02-04-070208_init_channels/up.sql b/migrations/2025-02-04-070208_init_channels/up.sql deleted file mode 100644 index acf2a89..0000000 --- a/migrations/2025-02-04-070208_init_channels/up.sql +++ /dev/null @@ -1,29 +0,0 @@ -CREATE TABLE IF NOT EXISTS channels ( - id UUID NOT NULL PRIMARY KEY, - team_id UUID NOT NULL REFERENCES teams(id), - name TEXT NOT NULL, - enable_by_default BOOLEAN NOT NULL DEFAULT FALSE -); - -CREATE TABLE IF NOT EXISTS email_channels ( - id UUID NOT NULL PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE, - recipient TEXT NOT NULL DEFAULT '', - verification_code TEXT NOT NULL DEFAULT '', - verification_code_guesses INT NOT NULL DEFAULT 0, - verified BOOLEAN NOT NULL DEFAULT FALSE -); - -CREATE TABLE IF NOT EXISTS slack_channels ( - id UUID NOT NULL PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE, - oauth_state TEXT NOT NULL DEFAULT '', - access_token TEXT NOT NULL DEFAULT '', - conversation_id TEXT NOT NULL DEFAULT '' -); - -CREATE TABLE IF NOT EXISTS channel_selections ( - project_id UUID NOT NULL REFERENCES projects(id), - channel_id UUID NOT NULL REFERENCES channels(id), - PRIMARY KEY (project_id, channel_id) -); -CREATE INDEX ON channel_selections (project_id); -CREATE INDEX ON channel_selections (channel_id); diff --git a/src/channels.rs b/src/channels.rs index ea7a026..5bfa340 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -1,64 +1,44 @@ -use anyhow::Context; -use deadpool_diesel::postgres::Connection; use diesel::{ - dsl::{auto_type, insert_into, AsSelect}, + backend::Backend, + deserialize::{self, FromSql, FromSqlRow}, + dsl::{auto_type, AsSelect}, + expression::AsExpression, pg::Pg, prelude::*, - Connection as _, + serialize::{Output, ToSql}, + sql_types::Jsonb, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use serde_json::json; use uuid::Uuid; -use crate::{ - app_error::AppError, - schema::{channels, email_channels, slack_channels}, - teams::Team, -}; +use crate::{schema::channels, teams::Team}; +pub const CHANNEL_BACKEND_EMAIL: &'static str = "email"; +pub const CHANNEL_BACKEND_SLACK: &'static str = "slack"; + +/** + * Represents a target/destination for messages, with the sender configuration + * defined in the backend_config field. A single channel may be attached to + * (in other words, "enabled" or "selected" for) any number of projects within + * the same team. + */ #[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)] #[diesel(belongs_to(Team))] +#[diesel(check_for_backend(Pg))] pub struct Channel { pub id: Uuid, pub team_id: Uuid, pub name: String, pub enable_by_default: bool, - - #[diesel(embed)] - pub email_data: Option, - #[diesel(embed)] - pub slack_data: Option, -} - -#[derive( - Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable, Serialize, -)] -#[diesel(belongs_to(Channel, foreign_key = id))] -pub struct EmailChannel { - pub id: Uuid, - pub recipient: String, - #[serde(skip_serializing)] - pub verification_code: String, - pub verification_code_guesses: i32, - pub verified: bool, -} - -#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] -#[diesel(belongs_to(Channel, foreign_key = id))] -pub struct SlackChannel { - pub id: Uuid, - pub oauth_state: String, - pub access_token: String, - pub conversation_id: String, + pub backend_config: BackendConfig, } impl Channel { #[auto_type(no_type_alias)] pub fn all() -> _ { let select: AsSelect = Channel::as_select(); - channels::table - .left_join(email_channels::table) - .left_join(slack_channels::table) - .select(select) + channels::table.select(select) } #[auto_type(no_type_alias)] @@ -70,31 +50,123 @@ impl Channel { pub fn with_team(team_id: Uuid) -> _ { channels::team_id.eq(team_id) } +} - pub async fn create_email_channel( - db_conn: &Connection, - team_id: Uuid, - ) -> Result { - let id = Uuid::now_v7(); - db_conn - .interact(move |conn| { - conn.transaction(move |conn| { - insert_into(channels::table) - .values(( - channels::id.eq(id.clone()), - channels::team_id.eq(team_id), - channels::name.eq("Untitled Email Channel"), - )) - .execute(conn)?; - insert_into(email_channels::table) - .values(email_channels::id.eq(id.clone())) - .execute(conn)?; - Self::all().filter(Self::with_id(id)).first(conn) - }) - }) - .await - .unwrap() - .context("Failed to insert new EmailChannel.") - .map_err(|err| err.into()) +/** + * Encapsulates any information that needs to be persisted for setting up or + * using a channel's backend (that is, email sender, Slack app, etc.). This + * configuration is encoded to a jsonb column in the database, which determines + * the channel type along with configuration details. + * + * Note: In a previous implementation, channel configuration was handled by + * creating a dedicated table for each channel type and joining them to the + * `channels` table in order to access configuration fields. The jsonb approach + * simplifies database management and lends itself to a cleaner Rust + * implementation in which this enum can be treated as a column type with + * enforcement of data structure invariants handled entirely in the to_sql() + * and from_sql() serialization/deserialization logic. + */ +#[derive(AsExpression, Clone, Debug, FromSqlRow, Deserialize, Serialize)] +#[diesel(sql_type = Jsonb)] +pub enum BackendConfig { + Email(EmailBackendConfig), + Slack(SlackBackendConfig), +} + +impl ToSql for BackendConfig { + fn to_sql<'a>(&self, out: &mut Output<'a, '_, Pg>) -> diesel::serialize::Result { + match self.clone() { + BackendConfig::Email(config) => ToSql::::to_sql( + &json!({ + "t": CHANNEL_BACKEND_EMAIL, + "config": config, + }), + &mut out.reborrow(), + ), + BackendConfig::Slack(config) => ToSql::::to_sql( + &json!({ + "t": CHANNEL_BACKEND_SLACK, + "config": config, + }), + &mut out.reborrow(), + ), + } + } +} + +impl FromSql for BackendConfig { + fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { + let value = >::from_sql(bytes)?; + #[derive(Deserialize)] + struct IntermediateBackendConfig { + pub t: String, + pub config: serde_json::Value, + } + let intermediate_value = IntermediateBackendConfig::deserialize(value)?; + match intermediate_value.t.as_str() { + CHANNEL_BACKEND_EMAIL => Ok(BackendConfig::Email(EmailBackendConfig::deserialize( + intermediate_value.config, + )?)), + CHANNEL_BACKEND_SLACK => Ok(BackendConfig::Slack(SlackBackendConfig::deserialize( + intermediate_value.config, + )?)), + t => Err(anyhow::anyhow!("backend type not recognized: {}", t).into()), + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct EmailBackendConfig { + pub recipient: String, + pub verification_code: String, + pub verification_code_guesses: u32, + pub verified: bool, +} + +impl TryFrom for EmailBackendConfig { + type Error = anyhow::Error; + + fn try_from(value: BackendConfig) -> Result { + if let BackendConfig::Email(config) = value { + Ok(config) + } else { + Err(anyhow::anyhow!( + "could not load backend config as email channel" + )) + } + } +} + +impl Into for EmailBackendConfig { + fn into(self) -> BackendConfig { + BackendConfig::Email(self) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SlackBackendConfig { + pub oauth_state: String, + pub access_token: String, + pub refresh_token: String, + pub conversation_id: String, +} + +impl TryFrom for SlackBackendConfig { + type Error = anyhow::Error; + + fn try_from(value: BackendConfig) -> Result { + if let BackendConfig::Slack(config) = value { + Ok(config) + } else { + Err(anyhow::anyhow!( + "could not load backend config as slack channel" + )) + } + } +} + +impl Into for SlackBackendConfig { + fn into(self) -> BackendConfig { + BackendConfig::Slack(self) } } diff --git a/src/messages.rs b/src/messages.rs index 4d95e41..0beac30 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -6,14 +6,14 @@ use diesel::{ }; use uuid::Uuid; -use crate::{projects::Project, schema::messages}; +use crate::{channels::Channel, schema::messages}; #[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)] #[diesel(table_name = messages)] -#[diesel(belongs_to(Project))] +#[diesel(belongs_to(Channel))] pub struct Message { pub id: Uuid, - pub project_id: Uuid, + pub channel_id: Uuid, pub created_at: DateTime, pub message: String, } @@ -26,17 +26,7 @@ impl Message { } #[auto_type(no_type_alias)] - pub fn with_project(project_id: Uuid) -> _ { - messages::project_id.eq(project_id) - } - - #[auto_type(no_type_alias)] - pub fn values_now(project_id: Uuid, message: String) -> _ { - let id: Uuid = Uuid::now_v7(); - ( - messages::id.eq(id), - messages::project_id.eq(project_id), - messages::message.eq(message), - ) + pub fn with_channel(channel_id: Uuid) -> _ { + messages::channel_id.eq(channel_id) } } diff --git a/src/projects.rs b/src/projects.rs index c9b5249..cf06f49 100644 --- a/src/projects.rs +++ b/src/projects.rs @@ -7,7 +7,7 @@ use uuid::Uuid; use crate::{ channels::Channel, - schema::{channel_selections, channels, email_channels, projects, slack_channels}, + schema::{channel_selections, channels, projects}, teams::Team, }; @@ -48,8 +48,6 @@ impl Project { let project_filter: Eq = channel_selections::project_id.eq(self.id); channels::table - .left_join(email_channels::table) - .left_join(slack_channels::table) .inner_join(channel_selections::table) .filter(project_filter) .select(select) diff --git a/src/router.rs b/src/router.rs index ac852f4..61ee518 100644 --- a/src/router.rs +++ b/src/router.rs @@ -27,13 +27,13 @@ use crate::{ app_state::{AppState, DbConn}, auth, channel_selections::ChannelSelection, - channels::Channel, + channels::{BackendConfig, Channel, EmailBackendConfig, CHANNEL_BACKEND_EMAIL}, csrf::generate_csrf_token, email::{MailSender as _, Mailer}, guards, nav_state::{Breadcrumb, NavState}, projects::Project, - schema::{self, channel_selections, channels, email_channels}, + schema::{self, channel_selections, channels}, settings::Settings, team_memberships::TeamMembership, teams::Team, @@ -41,6 +41,9 @@ use crate::{ v0_router, }; +const VERIFICATION_CODE_LEN: usize = 6; +const MAX_VERIFICATION_GUESSES: u32 = 100; + pub fn new_router(state: AppState) -> Router<()> { let base_path = state.settings.base_path.clone(); Router::new().nest( @@ -107,7 +110,7 @@ async fn teams_page( .set_base_path(&base_path) .push_slug(Breadcrumb { href: "teams".to_string(), - label: "New Team".to_string(), + label: "Teams".to_string(), }) .set_navbar_active_item("teams"); #[derive(Template)] @@ -339,8 +342,24 @@ async fn post_new_channel( let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?; + let channel_id = Uuid::now_v7(); let channel = match form_body.channel_type.as_str() { - "email" => Channel::create_email_channel(&db_conn, team.id.clone()).await?, + CHANNEL_BACKEND_EMAIL => db_conn + .interact::<_, Result>(move |conn| { + Ok(insert_into(channels::table) + .values(( + channels::id.eq(channel_id), + channels::team_id.eq(team_id), + channels::name.eq("Untitled Email Channel"), + channels::backend_config + .eq(Into::::into(EmailBackendConfig::default())), + )) + .returning(Channel::as_returning()) + .get_result(conn) + .context("Failed to insert new EmailChannel.")?) + }) + .await + .unwrap()?, _ => { return Err(AppError::BadRequestError( "Channel type not recognized.".to_string(), @@ -399,31 +418,29 @@ async fn channel_page( }) .set_navbar_active_item("channels"); - if channel.email_data.is_some() { - #[derive(Template)] - #[template(path = "channel-email.html")] - struct ResponseTemplate { - base_path: String, - channel: Channel, - csrf_token: String, - nav_state: NavState, - } - Ok(Html( - ResponseTemplate { - base_path, - channel, - csrf_token, - nav_state, + match channel.backend_config { + BackendConfig::Email(_) => { + #[derive(Template)] + #[template(path = "channel-email.html")] + struct ResponseTemplate { + base_path: String, + channel: Channel, + csrf_token: String, + nav_state: NavState, } - .render()?, - )) - } else if channel.slack_data.is_some() { - Err(anyhow::anyhow!("Slack channel config page is not yet implemented.").into()) - } else { - Err(anyhow::anyhow!( - "Channel doesn't have a recognized variant for which to render a config page." - ) - .into()) + Ok(Html( + ResponseTemplate { + base_path, + channel, + csrf_token, + nav_state, + } + .render()?, + )) + } + BackendConfig::Slack(_) => { + Err(anyhow::anyhow!("Slack channel config page is not yet implemented.").into()) + } } } @@ -473,6 +490,28 @@ async fn update_channel( .into_response()) } +/** + * Helper function to query a channel from the database by ID and team, and + * return an appropriate error if no such channel exists. + */ +fn get_channel_by_params( + conn: &mut PgConnection, + team_id: Uuid, + channel_id: Uuid, +) -> Result { + match Channel::all() + .filter(Channel::with_id(channel_id)) + .filter(Channel::with_team(team_id)) + .first(conn) + { + diesel::QueryResult::Err(diesel::result::Error::NotFound) => Err(AppError::NotFoundError( + "Channel with that team and ID not found.".to_string(), + )), + diesel::QueryResult::Err(err) => Err(err.into()), + diesel::QueryResult::Ok(channel) => Ok(channel), + } +} + #[derive(Deserialize)] struct UpdateChannelEmailRecipientFormBody { // Yes it's a mouthful, but it's only used twice @@ -503,49 +542,41 @@ async fn update_channel_email_recipient( let verification_code: String = rand::thread_rng() .sample_iter(&Uniform::try_from(0..9).unwrap()) - .take(6) + .take(VERIFICATION_CODE_LEN) .map(|n| n.to_string()) .collect(); - let verification_code_copy = verification_code.clone(); - let recipient_copy = form_body.recipient.clone(); - - let channel_id_filter = Channel::with_id(channel_id.clone()); - let email_channel_id_filter = email_channels::id.eq(channel_id.clone()); - let team_filter = Channel::with_team(team_id.clone()); - let updated_rows = db_conn - .interact(move |conn| { - if Channel::all() - .filter(channel_id_filter) - .filter(team_filter) - .filter(email_channel_id_filter.clone()) - .first(conn) - .optional() - .context("failed to check whether channel exists under team") - .map_err(Into::::into)? - .is_none() - { - return Err(AppError::NotFoundError( - "Channel with that team and ID not found.".to_string(), - )); - } - update(email_channels::table.filter(email_channel_id_filter)) - .set(( - email_channels::recipient.eq(recipient_copy), - email_channels::verification_code.eq(verification_code_copy), - email_channels::verification_code_guesses.eq(0), - )) - .execute(conn) - .map_err(Into::::into) - }) - .await - .unwrap()?; - if updated_rows != 1 { - return Err(anyhow!( - "Updating EmailChannel recipient, the channel was found but {} rows were updated.", - updated_rows - ) - .into()); + { + let verification_code = verification_code.clone(); + let recipient = form_body.recipient.clone(); + let channel_id = channel_id.clone(); + let team_id = team_id.clone(); + db_conn + .interact(move |conn| { + // TODO: transaction retries + conn.transaction::<_, AppError, _>(move |conn| { + let channel = get_channel_by_params(conn, team_id, channel_id)?; + let new_config = BackendConfig::Email(EmailBackendConfig { + recipient, + verification_code, + verification_code_guesses: 0, + ..channel.backend_config.try_into()? + }); + let num_rows = update(channels::table.filter(Channel::with_id(channel.id))) + .set(channels::backend_config.eq(new_config)) + .execute(conn)?; + if num_rows != 1 { + return Err(anyhow!( + "Updating EmailChannel recipient, the channel was found but {} rows were updated.", + num_rows + ) + .into()); + } + Ok(()) + }) + }) + .await + .unwrap()?; } tracing::debug!( @@ -600,49 +631,55 @@ async fn verify_email( guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?; guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; - let channel_id_filter = Channel::with_id(channel_id.clone()); - let email_channel_id_filter = email_channels::id.eq(channel_id.clone()); - let team_filter = Channel::with_team(team_id.clone()); - let updated_rows = db_conn - .interact(move |conn| { - if Channel::all() - .filter(channel_id_filter) - .filter(team_filter) - .filter(email_channel_id_filter.clone()) - .first(conn) - .optional() - .context("failed to check whether channel exists under team") - .map_err(Into::::into)? - .is_none() - { - return Err(AppError::NotFoundError( - "Channel with that team and ID not found.".to_string(), - )); - } - update(email_channels::table.filter(email_channel_id_filter)) - .set( - email_channels::verification_code_guesses - .eq(email_channels::verification_code_guesses + 1), - ) - .execute(conn) - .map_err(Into::::into)?; - update( - email_channels::table - .filter(email_channel_id_filter) - .filter(email_channels::verification_code.eq(form_body.code)), - ) - .set(email_channels::verified.eq(true)) - .execute(conn) - .map_err(Into::::into) - }) - .await - .unwrap()?; - if updated_rows != 1 { - return Err(AppError::BadRequestError( - "Verification code not accepted.".to_string(), - )); + if form_body.code.len() != VERIFICATION_CODE_LEN { + return Err(AppError::BadRequestError(format!( + "Verification code must be {} characters long.", + VERIFICATION_CODE_LEN + ))); } + { + let channel_id = channel_id.clone(); + let team_id = team_id.clone(); + let verification_code = form_body.code; + db_conn + .interact(move |conn| { + conn.transaction::<(), AppError, _>(move |conn| { + let channel = get_channel_by_params(conn, team_id, channel_id)?; + let config: EmailBackendConfig = channel.backend_config.try_into()?; + if config.verified { + return Err(AppError::BadRequestError( + "Channel's email address is already verified.".to_string(), + )); + } + if config.verification_code_guesses > MAX_VERIFICATION_GUESSES { + return Err(AppError::BadRequestError( + "Verification expired.".to_string(), + )); + } + let new_config = if config.verification_code == verification_code { + EmailBackendConfig { + verified: true, + verification_code: "".to_string(), + verification_code_guesses: 0, + ..config + } + } else { + EmailBackendConfig { + verification_code_guesses: config.verification_code_guesses + 1, + ..config + } + }; + update(channels::table.filter(Channel::with_id(channel_id))) + .set(channels::backend_config.eq(Into::::into(new_config))) + .execute(conn)?; + Ok(()) + }) + }) + .await + .unwrap()?; + }; + Ok(Redirect::to(&format!( "{}/teams/{}/channels/{}", base_path, diff --git a/src/schema.rs b/src/schema.rs index f77b142..11f4874 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -31,6 +31,7 @@ diesel::table! { team_id -> Uuid, name -> Text, enable_by_default -> Bool, + backend_config -> Jsonb, } } @@ -42,21 +43,12 @@ diesel::table! { } } -diesel::table! { - email_channels (id) { - id -> Uuid, - recipient -> Text, - verification_code -> Text, - verification_code_guesses -> Int4, - verified -> Bool, - } -} - diesel::table! { messages (id) { id -> Uuid, - project_id -> Uuid, + channel_id -> Uuid, created_at -> Timestamptz, + sent_at -> Nullable, message -> Text, } } @@ -69,15 +61,6 @@ diesel::table! { } } -diesel::table! { - slack_channels (id) { - id -> Uuid, - oauth_state -> Text, - access_token -> Text, - conversation_id -> Text, - } -} - diesel::table! { team_memberships (team_id, user_id) { team_id -> Uuid, @@ -106,10 +89,8 @@ diesel::joinable!(channel_selections -> channels (channel_id)); diesel::joinable!(channel_selections -> projects (project_id)); diesel::joinable!(channels -> teams (team_id)); diesel::joinable!(csrf_tokens -> users (user_id)); -diesel::joinable!(email_channels -> channels (id)); -diesel::joinable!(messages -> projects (project_id)); +diesel::joinable!(messages -> channels (channel_id)); diesel::joinable!(projects -> teams (team_id)); -diesel::joinable!(slack_channels -> channels (id)); diesel::joinable!(team_memberships -> teams (team_id)); diesel::joinable!(team_memberships -> users (user_id)); @@ -119,10 +100,8 @@ diesel::allow_tables_to_appear_in_same_query!( channel_selections, channels, csrf_tokens, - email_channels, messages, projects, - slack_channels, team_memberships, teams, users, diff --git a/src/v0_router.rs b/src/v0_router.rs index 96e1bf5..5f1e41a 100644 --- a/src/v0_router.rs +++ b/src/v0_router.rs @@ -14,6 +14,7 @@ use crate::{ api_keys::ApiKey, app_error::AppError, app_state::{AppState, DbConn}, + channels::{Channel, EmailBackendConfig}, email::{MailSender as _, Mailer}, projects::Project, schema::{api_keys, projects}, @@ -40,56 +41,59 @@ async fn say_get( DbConn(db_conn): DbConn, Query(query): Query, ) -> Result { - let key = query.key.clone(); - let maybe_api_key = db_conn - .interact(move |conn| { - update(api_keys::table.filter(ApiKey::with_id(key))) - .set(api_keys::last_used_at.eq(diesel::dsl::now)) - .returning(ApiKey::as_returning()) - .get_result(conn) - .optional() - }) - .await - .unwrap() - .context("unable to get API key")?; - let api_key = match maybe_api_key { - Some(api_key) => api_key, - None => return Err(AppError::ForbiddenError("key not accepted".to_string())), + // TODO: do some validation of message contents + let api_key = { + let query_key = query.key.clone(); + db_conn + .interact::<_, Result>(move |conn| { + update(api_keys::table.filter(ApiKey::with_id(query_key))) + .set(api_keys::last_used_at.eq(diesel::dsl::now)) + .returning(ApiKey::as_returning()) + .get_result(conn) + .optional() + .context("failed to get API key")? + .ok_or(AppError::ForbiddenError("Key not accepted.".to_string())) + }) + .await + .unwrap()? + }; + + let selected_channels = { + let project_name = query.project.to_lowercase(); + db_conn + .interact::<_, Result, AppError>>(move |conn| { + conn.transaction(move |conn| { + let project = match Project::all() + .filter(Project::with_team(api_key.team_id)) + .filter(Project::with_name(project_name.clone())) + .first(conn) + .optional() + .context("failed to load project")? + { + Some(project) => project, + None => insert_into(projects::table) + .values(( + projects::id.eq(Uuid::now_v7()), + projects::team_id.eq(api_key.team_id), + projects::name.eq(project_name), + )) + .get_result(conn) + .context("failed to insert project")?, + }; + Ok(project + .selected_channels() + .load(conn) + .context("failed to load selected channels")?) + }) + }) + .await + .unwrap()? }; - let project_name = query.project.to_lowercase(); - let selected_channels = db_conn - .interact(move |conn| { - insert_into(projects::table) - .values(( - projects::id.eq(Uuid::now_v7()), - projects::team_id.eq(api_key.team_id.clone()), - projects::name.eq(project_name.clone()), - )) - .on_conflict((projects::team_id, projects::name)) - .do_nothing() - .execute(conn) - .context("failed to insert project")?; - // It would be nice to merge these two database operations into one, - // but it's not trivial to do so without faking an update; refer to: - // https://stackoverflow.com/a/42217872 - let project = Project::all() - .filter(Project::with_team(api_key.team_id)) - .filter(Project::with_name(project_name)) - .first(conn) - .context("failed to load project")?; - project - .selected_channels() - .load(conn) - .context("failed to load selected channels") - }) - .await - .unwrap() - .context("unable to get project")?; for channel in selected_channels { - if let Some(email_data) = channel.email_data { - if email_data.verified { - let recipient: lettre::message::Mailbox = email_data.recipient.parse()?; + if let Ok(config) = TryInto::::try_into(channel.backend_config) { + if config.verified { + let recipient: lettre::message::Mailbox = config.recipient.parse()?; let email = crate::email::Message { from: email_settings.message_from.clone().into(), to: recipient.into(), diff --git a/templates/channel-email.html b/templates/channel-email.html index c180c0c..97e9ccb 100644 --- a/templates/channel-email.html +++ b/templates/channel-email.html @@ -3,123 +3,124 @@ {% block title %}Shout.dev: Channels{% endblock %} {% block main %} -{% let email_data = channel.email_data.clone().unwrap() %} -{% include "breadcrumbs.html" %} -
-
-

Channel Configuration

-
-
-
-
- - -
-
-
- - -
-
-
- - -
-
-
-
-
-
- - -
- {% if email_data.verified %} - Updating this will require verification of the new recipient. - {% else %} - Recipient must be verified before they can receive messages. - {% endif %} -
-
-
- - -
-
-
- {% if email_data.recipient != "" && !email_data.verified %} +{% if let BackendConfig::Email(email_data) = channel.backend_config %} + {% include "breadcrumbs.html" %} +
+
+

Channel Configuration

+
- + -
- Enter the most recent Shout.dev verification code for this address. +
+
+
+
- +
+
+
- - +
+ + +
+ {% if email_data.verified %} + Updating this will require verification of the new recipient. + {% else %} + Recipient must be verified before they can receive messages. + {% endif %} +
+
+
+ + +
- {% endif %} -
+ {% if email_data.recipient != "" && !email_data.verified %} +
+
+
+ + +
+ Enter the most recent Shout.dev verification code for this address. + +
+
+
+ + +
+
+
+ + +
+
+ {% endif %} +
+{% endif %} {% endblock %}