Files
lxc-streamutils/apps/server-rust/src/repository.rs
T

534 lines
18 KiB
Rust

//! 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<InMemoryComponentStore>,
}
impl TenantRepository {
pub fn new(db: Db, registry: ComponentRegistry, cache: Arc<InMemoryComponentStore>) -> Self {
Self {
db,
registry,
cache,
}
}
pub fn cache(&self) -> Arc<InMemoryComponentStore> {
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<Vec<ComponentView>, 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<Vec<ComponentView>, RepositoryError> {
self.hydrate_tenant(owner_id).await
}
pub async fn create_component(
&self,
owner_id: Uuid,
kind: &str,
name: &str,
) -> Result<ComponentInstance, RepositoryError> {
// 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<ComponentInstance, RepositoryError> {
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<ComponentInstance, RepositoryError> {
let current = self.get_component(owner_id, component_id).await?;
let validated = self
.registry
.validate_settings(&current.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<Uuid, RepositoryError> {
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<String, RepositoryError> {
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<ComponentTokenSummary, RepositoryError> {
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<u64, RepositoryError> {
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<bool, RepositoryError> {
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<Vec<InvitationView>, 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<DateTime<Utc>>>(5).is_some() {
1
} else {
0
},
})
.collect())
}
pub async fn legacy_overlay_settings(
&self,
room_id: &str,
fallback: Value,
) -> Result<Value, RepositoryError> {
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<ComponentInstance, RepositoryError> {
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<ComponentInstance, RepositoryError> {
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<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
}
#[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<Utc>,
pub expires_at: DateTime<Utc>,
pub consumed_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
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<DbError> for RepositoryError {
fn from(value: DbError) -> Self {
Self::Database(value)
}
}
impl From<tokio_postgres::Error> for RepositoryError {
fn from(value: tokio_postgres::Error) -> Self {
Self::Postgres(value)
}
}