//! Repository facade for tenant components, settings and the routing cache. //! //! PostgreSQL is authoritative. Successful writes are reflected into the //! in-process [`InMemoryComponentStore`] used by the hot event path; startup and //! account-source restarts hydrate that cache from tenant-scoped rows before events are //! routed. use std::{fmt, sync::Arc}; use chrono::{DateTime, Utc}; use serde::Serialize; use serde_json::Value; use uuid::Uuid; use crate::{ components::{ComponentInstance, ComponentRegistry}, db::{ComponentRecord, Db, DbError}, realtime::InMemoryComponentStore, song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings}, }; #[derive(Clone)] pub struct TenantRepository { db: Db, registry: ComponentRegistry, cache: Arc, } impl TenantRepository { pub fn new(db: Db, registry: ComponentRegistry, cache: Arc) -> Self { Self { db, registry, cache, } } pub fn cache(&self) -> Arc { self.cache.clone() } pub async fn hydrate_all(&self) -> Result<(), RepositoryError> { for tenant in self.db.list_active_tenants().await? { self.hydrate_tenant(tenant.user_id).await?; } Ok(()) } /// Ensure every active tenant has the built-in singleton song component. /// The partial unique index makes this safe across concurrent application /// starts; the state row is repaired independently for existing instances. pub async fn ensure_song_request_components(&self) -> Result<(), RepositoryError> { for tenant in self.db.list_active_tenants().await? { let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, tenant.user_id).await?; let existing = transaction .query_opt( "SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2", &[&tenant.user_id, &SONG_REQUEST_KIND], ) .await?; let component_id: Uuid = if let Some(row) = existing { row.get(0) } else { let id = Uuid::new_v4(); let settings = serde_json::to_value(SongRequestSettings::default()) .map_err(|error| RepositoryError::Invalid(error.to_string()))?; transaction .execute( "INSERT INTO component_instances \ (id,owner_user_id,kind,name,settings,settings_version,enabled) \ VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING", &[ &id, &tenant.user_id, &SONG_REQUEST_KIND, &SONG_REQUEST_NAME, &settings, ], ) .await?; transaction .query_one( "SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2", &[&tenant.user_id, &SONG_REQUEST_KIND], ) .await? .get(0) }; transaction .execute( "INSERT INTO song_request_state(owner_user_id,component_instance_id) \ VALUES($1,$2) ON CONFLICT(component_instance_id) DO NOTHING", &[&tenant.user_id, &component_id], ) .await?; transaction.commit().await?; } Ok(()) } pub async fn hydrate_tenant( &self, owner_id: Uuid, ) -> Result, RepositoryError> { let rows = self.db.list_tenant_components(owner_id).await?; let mut views = Vec::with_capacity(rows.len()); for row in rows { let component = self.validate_loaded_component(component_from_record(row)?)?; self.cache.upsert(component.clone()); views.push(ComponentView::from(&component)); } Ok(views) } pub async fn list_components( &self, owner_id: Uuid, ) -> Result, RepositoryError> { self.hydrate_tenant(owner_id).await } pub async fn create_component( &self, owner_id: Uuid, kind: &str, name: &str, ) -> Result { // Every tenant receives this singleton during registration/startup. // Keeping creation internal prevents a second instance from racing the // partial unique index and turning a domain conflict into a DB error. if kind == SONG_REQUEST_KIND { return Err(RepositoryError::Forbidden); } let runtime = self .registry .runtime(kind) .map_err(|error| RepositoryError::Invalid(error.to_string()))?; let name = name.trim(); if name.is_empty() || name.chars().count() > 80 { return Err(RepositoryError::Invalid( "component name must contain 1-80 characters".into(), )); } let source_id = self.source_id(owner_id).await?; let component = ComponentInstance::new( owner_id, source_id, runtime.kind(), name, runtime.definition().settings_version(), runtime.definition().default_settings(), ); let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, owner_id).await?; transaction .execute( "INSERT INTO component_instances \ (id,owner_user_id,kind,name,settings,settings_version,enabled) \ VALUES($1,$2,$3,$4,$5,$6,true)", &[ &component.id, &owner_id, &component.kind, &component.name, &component.settings, &(component.settings_version as i32), ], ) .await?; transaction.commit().await?; self.cache.upsert(component.clone()); Ok(component) } pub async fn delete_component( &self, owner_id: Uuid, component_id: Uuid, ) -> Result<(), RepositoryError> { let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, owner_id).await?; let kind: String = transaction .query_opt( "SELECT kind FROM component_instances WHERE owner_user_id=$1 AND id=$2", &[&owner_id, &component_id], ) .await? .ok_or(RepositoryError::NotFound)? .get(0); if kind == SONG_REQUEST_KIND { return Err(RepositoryError::Forbidden); } let changed = transaction .execute( "DELETE FROM component_instances WHERE owner_user_id=$1 AND id=$2", &[&owner_id, &component_id], ) .await?; if changed != 1 { return Err(RepositoryError::NotFound); } transaction.commit().await?; self.cache.remove(component_id); Ok(()) } pub async fn get_component( &self, owner_id: Uuid, component_id: Uuid, ) -> Result { let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, owner_id).await?; let row = transaction .query_opt( "SELECT component.id,source.id,component.kind,component.name,component.settings,\ component.settings_version,component.enabled \ FROM component_instances AS component \ JOIN live_sources AS source ON source.owner_user_id=component.owner_user_id \ WHERE component.owner_user_id=$1 AND component.id=$2", &[&owner_id, &component_id], ) .await? .ok_or(RepositoryError::NotFound)?; transaction.commit().await?; self.validate_loaded_component(component_from_record(ComponentRecord { id: row.get(0), owner_user_id: owner_id, account_source_id: row.get(1), kind: row.get(2), name: row.get(3), settings: row.get(4), settings_version: row.get(5), enabled: row.get(6), })?) } pub async fn update_component_settings( &self, owner_id: Uuid, component_id: Uuid, settings: Value, ) -> Result { let current = self.get_component(owner_id, component_id).await?; let validated = self .registry .validate_settings(¤t.kind, current.settings_version, settings) .map_err(|error| RepositoryError::Invalid(error.to_string()))?; let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, owner_id).await?; let changed = transaction .execute( "UPDATE component_instances SET settings=$1,updated_at=now() \ WHERE id=$2 AND owner_user_id=$3", &[&validated, &component_id, &owner_id], ) .await?; if changed != 1 { return Err(RepositoryError::NotFound); } transaction.commit().await?; let updated = ComponentInstance { settings: validated, ..current }; self.cache.upsert(updated.clone()); Ok(updated) } pub async fn source_id(&self, owner_id: Uuid) -> Result { let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, owner_id).await?; let row = transaction .query_opt( "SELECT id FROM live_sources WHERE owner_user_id=$1 AND enabled", &[&owner_id], ) .await? .ok_or(RepositoryError::NotFound)?; transaction.commit().await?; Ok(row.get(0)) } pub async fn room_id(&self, owner_id: Uuid) -> Result { let client = self.db.get().await?; let row = client .query_opt( "SELECT room_id FROM users WHERE id=$1 AND status='active'", &[&owner_id], ) .await? .ok_or(RepositoryError::NotFound)?; Ok(row.get(0)) } pub async fn token_summary( &self, owner_id: Uuid, component_id: Uuid, ) -> Result { let _ = self.get_component(owner_id, component_id).await?; let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, owner_id).await?; let row = transaction .query_opt( "SELECT created_at,last_used_at FROM component_access_tokens \ WHERE owner_user_id=$1 AND component_instance_id=$2 AND revoked_at IS NULL \ AND (expires_at IS NULL OR expires_at>now()) ORDER BY created_at DESC LIMIT 1", &[&owner_id, &component_id], ) .await?; transaction.commit().await?; Ok(ComponentTokenSummary { configured: row.is_some(), updated_at: row.as_ref().map(|row| row.get(0)), last_used_at: row.as_ref().and_then(|row| row.get(1)), }) } pub async fn revoke_component_tokens( &self, owner_id: Uuid, component_id: Uuid, ) -> Result { let _ = self.get_component(owner_id, component_id).await?; let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, owner_id).await?; let changed = transaction .execute( "UPDATE component_access_tokens SET revoked_at=now() \ WHERE owner_user_id=$1 AND component_instance_id=$2 AND revoked_at IS NULL", &[&owner_id, &component_id], ) .await?; transaction.commit().await?; Ok(changed) } pub async fn setup_required(&self) -> Result { let client = self.db.get().await?; Ok(!client .query_one("SELECT EXISTS(SELECT 1 FROM users)", &[]) .await? .get::<_, bool>(0)) } pub async fn list_invitations( &self, actor_id: Uuid, ) -> Result, RepositoryError> { let client = self.db.get().await?; require_system_admin(&client, actor_id).await?; let rows = client .query( "SELECT id,code_prefix,room_id,created_at,expires_at,consumed_at,revoked_at \ FROM invitations WHERE grant_role='user' ORDER BY created_at DESC LIMIT 250", &[], ) .await?; Ok(rows .into_iter() .map(|row| InvitationView { id: row.get(0), code_prefix: row.get(1), room_id: row.get(2), created_at: row.get(3), expires_at: row.get(4), consumed_at: row.get(5), revoked_at: row.get(6), max_uses: 1, used_count: if row.get::<_, Option>>(5).is_some() { 1 } else { 0 }, }) .collect()) } pub async fn legacy_overlay_settings( &self, room_id: &str, fallback: Value, ) -> Result { let client = self.db.get().await?; Ok(client .query_opt( "SELECT settings FROM overlay_settings WHERE room_id=$1", &[&room_id], ) .await? .map(|row| row.get(0)) .unwrap_or(fallback)) } fn validate_loaded_component( &self, mut component: ComponentInstance, ) -> Result { component.settings = self .registry .validate_settings( &component.kind, component.settings_version, component.settings, ) .map_err(|error| RepositoryError::Invalid(error.to_string()))?; Ok(component) } } fn component_from_record(record: ComponentRecord) -> Result { let settings_version = u32::try_from(record.settings_version) .map_err(|_| RepositoryError::Invalid("negative settings version".into()))?; Ok(ComponentInstance { id: record.id, owner_id: record.owner_user_id, account_source_id: record.account_source_id, kind: record.kind, name: record.name, enabled: record.enabled, settings_version, settings: record.settings, }) } async fn require_system_admin( client: &deadpool_postgres::Object, actor_id: Uuid, ) -> Result<(), RepositoryError> { let allowed = client .query_one( "SELECT EXISTS(SELECT 1 FROM users WHERE id=$1 AND role='system_admin' AND status='active')", &[&actor_id], ) .await? .get::<_, bool>(0); if allowed { Ok(()) } else { Err(RepositoryError::Forbidden) } } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ComponentView { pub id: Uuid, pub public_id: Uuid, pub kind: String, pub name: String, pub enabled: bool, pub settings: Value, } impl From<&ComponentInstance> for ComponentView { fn from(component: &ComponentInstance) -> Self { Self { id: component.id, public_id: component.id, kind: component.kind.clone(), name: component.name.clone(), enabled: component.enabled, settings: component.settings.clone(), } } } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ComponentTokenSummary { pub configured: bool, pub updated_at: Option>, pub last_used_at: Option>, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct InvitationView { pub id: Uuid, pub code_prefix: String, pub room_id: String, pub created_at: DateTime, pub expires_at: DateTime, pub consumed_at: Option>, pub revoked_at: Option>, pub max_uses: i32, pub used_count: i32, } #[derive(Debug)] pub enum RepositoryError { NotFound, Forbidden, Invalid(String), Database(DbError), Postgres(tokio_postgres::Error), } impl fmt::Display for RepositoryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::NotFound => formatter.write_str("resource was not found"), Self::Forbidden => formatter.write_str("operation is not permitted"), Self::Invalid(message) => write!(formatter, "invalid value: {message}"), Self::Database(error) => error.fmt(formatter), Self::Postgres(error) => error.fmt(formatter), } } } impl std::error::Error for RepositoryError {} impl From for RepositoryError { fn from(value: DbError) -> Self { Self::Database(value) } } impl From for RepositoryError { fn from(value: tokio_postgres::Error) -> Self { Self::Postgres(value) } }