Skip to main content

Admin & Property Manager Roles

This document covers administrative roles, portfolio management, and Property Manager (PM) specific functionality.

Key Files

  • crates/bf_resident/src/handlers/portfolios.rs - Portfolio CRUD operations
  • crates/bf_resident/src/handlers/pm_filtered.rs - PM-scoped handlers
  • crates/clerk-auth/src/roles.rs - Role definitions and checks
  • crates/bf_user/src/permissions.rs - DO-level permission enforcement
  • crates/bf_resident_db/src/operations/portfolios.rs - Portfolio database operations

Role Hierarchy

┌─────────────────────────────────────────────────────────────────────┐
│ Role Hierarchy │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Admin ─────────────────────────────────────────────────────────────│
│ │ • Full system access │
│ │ • All portfolios and properties │
│ │ • User management (via Clerk) │
│ │ │
│ ├── ItAdmin ─────────────────────────────────────────────────────│
│ │ │ • Device commissioning │
│ │ │ • SmartThings/Yale/Hive setup │
│ │ │ • Credential management │
│ │ │ │
│ │ └── PropertyManager ───────────────────────────────────────│
│ │ │ • Portfolio-scoped access │
│ │ │ • Tenant management │
│ │ │ • PM PIN codes (vacant properties) │
│ │ │ • Property lifecycle transitions │
│ │ │ │
│ │ └── Installer ───────────────────────────────────────│
│ │ │ • Device installation │
│ │ │ • Commissioning tasks │
│ │ │ • Portfolio-assigned access │
│ │ │ │
│ │ └── Tenant ────────────────────────────────────│
│ │ │ • Property access │
│ │ │ • Guest management │
│ │ │ • Device control │
│ │ │ │
│ │ └── Guest ───────────────────────────────│
│ │ • Limited device access │
│ │ • Time-restricted access │
│ │ • Granular permissions │
│ │ │
└─────────────────────────────────────────────────────────────────────┘

Portfolio Model

Portfolios group properties under common management. They define PM assignments and default installers.

Portfolio Structure

pub struct Portfolio {
pub id: String,
pub name: String,
pub description: Option<String>,
pub property_count: u32,

/// PMs assigned to this portfolio (email addresses)
pub assigned_pms: Vec<String>,

/// Default installers for new properties
pub default_installers: Option<Vec<String>>,

/// Housebuilder/developer reference
pub housebuilder: Option<String>,

/// Site map configuration (for installer app)
pub site_map_data: Option<InstallerSiteMapData>,

pub created_at: i64,
pub created_by: String,
pub last_activity: i64,
}

Portfolio Operations

OperationEndpointRole Required
Create PortfolioPOST /admin/portfoliosAdmin
Get PortfolioGET /portfolios/{id}PM (if assigned) or Admin
Update PortfolioPUT /admin/portfolios/{id}Admin
Delete PortfolioDELETE /admin/portfolios/{id}Admin
Add PMPOST /admin/portfolios/{id}/property-managers/{user_id}Admin
Remove PMDELETE /admin/portfolios/{id}/property-managers/{user_id}Admin
List PropertiesGET /portfolios/{id}/propertiesPM (if assigned) or Admin

Portfolio Deletion Rules

Portfolios can only be deleted if they have no properties:

// crates/bf_resident/src/handlers/portfolios.rs:129

let count_result = sqlx_d1::query!(
"SELECT COUNT(*) as count FROM properties WHERE portfolio_id = ?",
id
)
.fetch_one(&mut conn)
.await?;

if count > 0 {
return Err(AppError::BadRequest(
"Cannot delete portfolio with existing properties".to_string(),
));
}

PM-Filtered Access

Property Managers have access filtered to their assigned portfolios.

Automatic Filtering

// crates/bf_resident/src/handlers/pm_filtered.rs:42

pub async fn list_portfolios_pm(
Extension(auth): Extension<ClerkAuth>,
State(env): State<Env>,
Query(mut filters): Query<PortfolioFilters>,
) -> Result<Json<PortfolioListResponse>> {
let user_id = auth.user_id().to_string();

// If not admin, force filter by assigned PM
if !is_admin(&auth) {
filters.assigned_pm = Some(user_id);
}
// ...
}

Portfolio Assignment Check

// crates/bf_resident/src/handlers/pm_filtered.rs:85

pub async fn get_portfolio_id_by_name(
Extension(auth): Extension<ClerkAuth>,
State(env): State<Env>,
Path(name): Path<String>,
) -> Result<Json<IdResponse>> {
let portfolio = get_portfolio_by_name(&mut conn, &name.to_lowercase()).await?;

// Check if PM has access (admins can access all)
if !is_admin(&auth) {
let user_id = auth.user_id().to_string();
if !portfolio.assigned_pms.contains(&user_id) {
return Err(AppError::Forbidden(
"Not assigned to this portfolio".to_string(),
));
}
}
// ...
}

PM PIN Code Management

PMs can create temporary PIN codes for vacant properties (Commissioned or PreTenancy status).

Property Access Verification

// crates/bf_resident/src/handlers/pm_filtered.rs:138

pub async fn verify_pm_property_access(
conn: &mut D1Connection,
auth: &ClerkAuth,
property_id: &str,
) -> Result<Property> {
let property = get_property_by_id(conn, property_id).await?;

// Check PM has access to portfolio
if !is_admin(auth) {
let portfolio = get_portfolio_by_id(conn, &property.portfolio_id).await?;
if !portfolio.assigned_pms.contains(&auth.user_id()) {
return Err(AppError::Forbidden("Not assigned to this portfolio"));
}
}

// Verify property status is Commissioned or PreTenancy
if !matches!(
property.status,
PropertyStatus::Commissioned | PropertyStatus::PreTenancy
) {
return Err(AppError::BadRequest(
"PIN code management only available for Commissioned or Pre-Tenancy properties"
));
}

Ok(property)
}

PM PIN Code Types

Two metadata types define PM access scenarios:

pub enum PmAccessMetadata {
/// Single-day viewing with time window
TenantViewing {
date: String, // "YYYY-MM-DD"
start_time: String, // "HH:MM"
end_time: String, // "HH:MM"
tenant_name: Option<String>,
tenant_email: Option<String>,
tenant_phone: Option<String>,
additional_details: Option<String>,
},

/// Multi-day contractor access
ContractorVisit {
contractor_type: ContractorType,
date_start: String, // "YYYY-MM-DD"
date_end: String, // "YYYY-MM-DD"
start_time: String, // Daily "HH:MM"
end_time: String, // Daily "HH:MM"
additional_details: Option<String>,
},
}

PIN Code Constraints

ConstraintTenant ViewingContractor Visit
DurationMax 10 hoursMax 14 days
Time WindowSingle windowDaily recurring
Status RequiredCommissioned/PreTenancyCommissioned/PreTenancy
/// Maximum days for PM-created PIN codes
pub const PM_PIN_CODE_MAX_DAYS: i64 = 14;

// Validate viewing duration (max 10 hours = 600 minutes)
let duration_minutes = end_minutes - start_minutes;
if duration_minutes > 600 {
return Err(AppError::BadRequest(
"PIN code duration cannot exceed 10 hours"
));
}

PM Security Controls

PMs can monitor and control security for vacant properties.

Security Status

// GET /pm/properties/{property_id}/security-status

pub struct SecurityStatusResponse {
pub locks: Vec<LockStatus>,
pub alarm: Option<AlarmStatus>,
pub issues: Vec<SecurityIssue>,
pub last_updated: i64,
}

Available Actions

ActionEndpointDescription
Lock DoorPOST /pm/properties/{id}/lockRemote lock
Arm AlarmPOST /pm/properties/{id}/arm-alarmSet to Away mode
Get StatusGET /pm/properties/{id}/security-statusCurrent state

Security Reminder Emails

When a PM creates a PIN code, they receive an email reminder:

// Email includes:
// - Property address
// - Access type and schedule
// - Security reminder checklist:
// - Check door is locked
// - Check alarm is armed
// - Verify all secure

Activity Logging

All PM actions are logged for audit purposes:

let activity = CreateActivityLogRequest {
property_id: Some(property_id.clone()),
portfolio_id: Some(property.portfolio_id.clone()),
user_email: pm_email.clone(),
action: "pm_access_code_created".to_string(),
description: format!(
"PM created access code '{}': {}",
request.name.trim(),
details
),
metadata: Some(serde_json::json!({
"pin_code_id": code.id,
"pin_code_name": code.name,
"access_type": access_type,
"valid_from": valid_from,
"valid_until": valid_until,
})),
};

Logged Actions

  • pm_access_code_created - PIN code created by PM
  • pm_access_code_revoked - PIN code revoked by PM
  • pm_added - PM added to portfolio
  • pm_removed - PM removed from portfolio
  • portfolio_updated - Portfolio configuration changed
  • Property status transitions

Slug-Based Routing

PMs can access resources via human-readable slugs:

// Get portfolio ID by name
// GET /portfolios/by-name/{name}
pub async fn get_portfolio_id_by_name(name: Path<String>) -> Json<IdResponse>

// Get property ID by portfolio name and address
// GET /portfolios/by-name/{portfolio_name}/properties/by-address/{address}
pub async fn get_property_id_by_address(
(portfolio_name, address): Path<(String, String)>
) -> Json<IdResponse>

Admin-Only Operations

Operations restricted to Admin role:

OperationDescription
Portfolio CRUDCreate, update, delete portfolios
PM AssignmentAdd/remove PMs from portfolios
Bulk Property CreationCreate multiple properties at once
User InvitesSend Clerk invitations
System ConfigurationEnvironment settings

Installer Role

Installers are assigned to portfolios via default_installers:

pub struct Portfolio {
// ...
/// Default installers auto-assigned to new properties
pub default_installers: Option<Vec<String>>,
}

Installer Capabilities

  • View assigned properties in commissioning status
  • Complete device installation tasks
  • Access site maps for property location
  • Mark commissioning steps complete

Site Map Access

Installers can view portfolio site maps:

// GET /installer/portfolios/{portfolio_id}/site-map

pub struct InstallerSiteMapData {
pub image_url: String, // Presigned URL (1 hour validity)
pub image_id: String,
pub lat: f64,
pub lng: f64,
pub opacity: f64,
pub bounds: Option<SiteMapBounds>,
}

Role Checking Functions

// crates/clerk-auth/src/roles.rs

pub fn is_admin(auth: &ClerkAuth) -> bool {
auth.roles().contains(&Role::Admin)
}

pub fn is_it_admin(auth: &ClerkAuth) -> bool {
is_admin(auth) || auth.roles().contains(&Role::ItAdmin)
}

pub fn is_property_manager(auth: &ClerkAuth) -> bool {
is_it_admin(auth) || auth.roles().contains(&Role::PropertyManager)
}

pub fn is_installer(auth: &ClerkAuth) -> bool {
is_property_manager(auth) || auth.roles().contains(&Role::Installer)
}

See Also