Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/crm/src/domain/stages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::domain::{
mod test;

/// Name of the team-scoped stage definition; `Stage` is reserved by a trigger.
pub const CRM_TEAM_STAGE_DEFINITION_NAME: &str = "Deal Stage";
pub use properties::CRM_TEAM_STAGE_DEFINITION_NAME;

/// Maximum stages in one pipeline.
pub const MAX_STAGES: usize = 50;
Expand Down
23 changes: 23 additions & 0 deletions crates/properties/fixtures/team_stage.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- One Deal Stage definition per team, shaped like the CRM stage service creates them.
-- company1 has a value on both, so a viewer must only see their own team's.
INSERT INTO property_definitions (id, team_id, user_id, display_name, data_type, is_multi_select, specific_entity_type)
VALUES
('dd111111-1111-1111-1111-111111111111', '0e000000-0000-0000-0000-000000000001', NULL, 'Deal Stage', 'SELECT_STRING', false, NULL),
('dd222222-2222-2222-2222-222222222222', '0e000000-0000-0000-0000-000000000002', NULL, 'Deal Stage', 'SELECT_STRING', false, NULL),
-- Same shape, different name: must not be picked up.
('dd333333-3333-3333-3333-333333333333', '0e000000-0000-0000-0000-000000000001', NULL, 'Region', 'SELECT_STRING', false, NULL)
ON CONFLICT (id) DO NOTHING;

INSERT INTO property_options (id, property_definition_id, display_order, number_value, string_value, color)
VALUES
('0dd11111-1111-1111-1111-111111111111', 'dd111111-1111-1111-1111-111111111111', 0, NULL, 'Lead', NULL),
('0dd11111-1111-1111-1111-111111111112', 'dd111111-1111-1111-1111-111111111111', 1, NULL, 'Customer', NULL),
('0dd22222-2222-2222-2222-222222222222', 'dd222222-2222-2222-2222-222222222222', 0, NULL, 'Prospect', NULL),
('0dd33333-3333-3333-3333-333333333333', 'dd333333-3333-3333-3333-333333333333', 0, NULL, 'EMEA', NULL)
ON CONFLICT (id) DO NOTHING;

INSERT INTO entity_properties (id, entity_id, entity_type, property_definition_id, values)
VALUES
('e0888888-8888-8888-8888-888888888881', 'company1', 'COMPANY', 'dd111111-1111-1111-1111-111111111111', '{"type": "SelectOption", "value": ["0dd11111-1111-1111-1111-111111111112"]}'),
('e0888888-8888-8888-8888-888888888882', 'company1', 'COMPANY', 'dd222222-2222-2222-2222-222222222222', '{"type": "SelectOption", "value": ["0dd22222-2222-2222-2222-222222222222"]}'),
('e0888888-8888-8888-8888-888888888883', 'company1', 'COMPANY', 'dd333333-3333-3333-3333-333333333333', '{"type": "SelectOption", "value": ["0dd33333-3333-3333-3333-333333333333"]}');
3 changes: 3 additions & 0 deletions crates/properties/src/domain/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ use models_properties::service::property_value::PropertyValue;
use models_properties::{DataType, EntityReference, EntityType, PropertyOwner};
use uuid::Uuid;

/// Name of a team's CRM stage definition. Written by the CRM crate, read by the loaders here.
pub const CRM_TEAM_STAGE_DEFINITION_NAME: &str = "Deal Stage";

/// Map an internal properties storage type to its canonical entity type.
pub fn canonical_entity_type(entity_type: EntityType) -> AccessEntityType {
match entity_type {
Expand Down
5 changes: 3 additions & 2 deletions crates/properties/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ pub mod outbound;

pub use domain::error::PropertiesErr;
pub use domain::model::{
EditReceipt, EntityPropertiesKey, EntityPropertyInfo, PropertyAccessReceiptExt,
PropertyOptionInfo, PropertyTargetKey, ViewReceipt, canonical_entity_type,
CRM_TEAM_STAGE_DEFINITION_NAME, EditReceipt, EntityPropertiesKey, EntityPropertyInfo,
PropertyAccessReceiptExt, PropertyOptionInfo, PropertyTargetKey, ViewReceipt,
canonical_entity_type,
};
pub use domain::ports::{NotificationService, PermissionService, PropertiesRepo};
pub use domain::service::{PropertiesService, TeamReceipt};
Expand Down
29 changes: 21 additions & 8 deletions crates/properties/src/outbound/entity_properties_get_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ use sqlx::{Pool, Postgres};
use uuid::Uuid;

use super::property_option_queries;
use crate::domain::model::{EntityPropertyInfo, PropertyOptionInfo};
use crate::domain::model::{
CRM_TEAM_STAGE_DEFINITION_NAME, EntityPropertyInfo, PropertyOptionInfo,
};

/// Database row from the joined query.
struct PropertyRow {
Expand Down Expand Up @@ -627,17 +629,18 @@ WHERE (ep.entity_id, ep.entity_type) IN (

/// Gets entity properties with their definitions and values for multiple entities, filtered by property definition IDs.
/// Returns a HashMap where the key is the entity_id and the value is Vec<EntityPropertyWithDefinition>.
/// Only returns properties matching the specified property_ids. When `tag_viewer_user_id` is set,
/// also returns TAG properties whose definition is owned by that user or their team.
/// Only returns properties matching the specified property_ids. When `viewer_user_id` is set,
/// also returns TAG properties owned by that user or their team, and the team's CRM stage
/// definition ([`CRM_TEAM_STAGE_DEFINITION_NAME`]).
#[tracing::instrument(skip(pool))]
pub async fn get_bulk_entity_properties_values_filtered(
pool: &Pool<Postgres>,
entity_refs: &[EntityReference],
property_ids: &[Uuid],
tag_viewer_user_id: Option<&macro_user_id::user_id::MacroUserIdStr<'_>>,
viewer_user_id: Option<&macro_user_id::user_id::MacroUserIdStr<'_>>,
) -> anyhow::Result<HashMap<String, Vec<EntityPropertyWithDefinition>>> {
let tag_viewer_user_id: Option<&str> = tag_viewer_user_id.map(|u| u.as_ref());
if entity_refs.is_empty() || (property_ids.is_empty() && tag_viewer_user_id.is_none()) {
let viewer_user_id: Option<&str> = viewer_user_id.map(|u| u.as_ref());
if entity_refs.is_empty() || (property_ids.is_empty() && viewer_user_id.is_none()) {
// If no property_ids specified, return empty map for each entity
let mut result = HashMap::new();
for entity_ref in entity_refs {
Expand Down Expand Up @@ -683,13 +686,23 @@ AND (
OR pd.team_id IN (SELECT tu.team_id FROM team_user tu WHERE tu.user_id = $4)
)
)
OR (
$4::text IS NOT NULL
AND pd.is_system = FALSE
AND pd.is_multi_select = FALSE
AND pd.data_type = $6
AND pd.display_name = $7
AND pd.team_id IN (SELECT tu.team_id FROM team_user tu WHERE tu.user_id = $4)
)
)
"#,
&entity_ids,
&entity_types as &[EntityType],
&property_ids,
tag_viewer_user_id,
DataType::Tag as DataType
viewer_user_id,
DataType::Tag as DataType,
DataType::SelectString as DataType,
CRM_TEAM_STAGE_DEFINITION_NAME
)
.fetch_all(pool)
.await?;
Expand Down
93 changes: 93 additions & 0 deletions crates/properties/src/outbound/entity_properties_values_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,3 +579,96 @@ async fn get_property_options_batch(pool: Pool<Postgres>) -> anyhow::Result<()>

Ok(())
}

/// Definition ids returned for company1 for the given ids and viewer.
async fn company1_definition_ids(
pool: &Pool<Postgres>,
property_ids: &[Uuid],
viewer: Option<&macro_user_id::user_id::MacroUserIdStr<'_>>,
) -> anyhow::Result<Vec<Uuid>> {
let entity_refs = vec![EntityReference {
entity_id: "company1".to_string(),
entity_type: EntityType::Company,
specific_message_id: None,
}];
let map = entity_properties_get_query::get_bulk_entity_properties_values_filtered(
pool,
&entity_refs,
property_ids,
viewer,
)
.await?;
let mut ids: Vec<Uuid> = map["company1"].iter().map(|p| p.definition.id).collect();
ids.sort();
Ok(ids)
}

#[sqlx::test(
migrator = "MACRO_DB_MIGRATIONS",
fixtures(path = "../../fixtures", scripts("properties", "team_stage"))
)]
async fn get_bulk_filtered_includes_viewer_team_deal_stage(
pool: Pool<Postgres>,
) -> anyhow::Result<()> {
let team1_stage = Uuid::parse_str("dd111111-1111-1111-1111-111111111111")?;
let team2_stage = Uuid::parse_str("dd222222-2222-2222-2222-222222222222")?;
let team1_region = Uuid::parse_str("dd333333-3333-3333-3333-333333333333")?;
let customer_option = Uuid::parse_str("0dd11111-1111-1111-1111-111111111112")?;
let user1 =
macro_user_id::user_id::MacroUserIdStr::parse_from_str("macro|user1@test.com").unwrap();
let user2 =
macro_user_id::user_id::MacroUserIdStr::parse_from_str("macro|user2@test.com").unwrap();
let user3 =
macro_user_id::user_id::MacroUserIdStr::parse_from_str("macro|user3@test.com").unwrap();

// Each viewer sees only their own team's Deal Stage.
assert_eq!(
company1_definition_ids(&pool, &[], Some(&user1)).await?,
vec![team1_stage]
);
assert_eq!(
company1_definition_ids(&pool, &[], Some(&user3)).await?,
vec![team1_stage]
);
assert_eq!(
company1_definition_ids(&pool, &[], Some(&user2)).await?,
vec![team2_stage]
);

// Options come with it.
let entity_refs = vec![EntityReference {
entity_id: "company1".to_string(),
entity_type: EntityType::Company,
specific_message_id: None,
}];
let map = entity_properties_get_query::get_bulk_entity_properties_values_filtered(
&pool,
&entity_refs,
&[],
Some(&user1),
)
.await?;
let stage = &map["company1"][0];
assert_eq!(stage.definition.display_name, "Deal Stage");
assert!(
stage
.options
.as_deref()
.unwrap_or_default()
.iter()
.any(|option| option.id == customer_option)
);

// Requested ids still come back too.
let mut expected = vec![team1_stage, team1_region];
expected.sort();
assert_eq!(
company1_definition_ids(&pool, &[team1_region], Some(&user1)).await?,
expected
);

// No viewer, nothing extra.
assert!(company1_definition_ids(&pool, &[], None).await?.is_empty());

Ok(())
}
5 changes: 3 additions & 2 deletions crates/soup/src/outbound/pg_soup_repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,9 @@ fn type_err<E: std::fmt::Display>(e: E) -> sqlx::Error {
///
/// This helper collects entity references from items that support properties
/// and performs one bulk lookup. System properties are always included, plus
/// the caller's own and team tag properties. Tasks use `EntityType::Task` while
/// regular documents use `EntityType::Document`.
/// the caller's own and team tag properties and the team's CRM stage
/// definition. Tasks use `EntityType::Task` while regular documents use
/// `EntityType::Document`.
#[tracing::instrument(err, skip(db, items))]
pub(crate) async fn populate_properties(
db: &sqlx::PgPool,
Expand Down
Loading