Files
lxc-streamutils/apps/server-rust/src/repository.rs
T
2026-08-19 12:30:30 -07:00

720 lines
26 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},
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
guard_effect::{
GUARD_EFFECT_KIND, GUARD_EFFECT_NAME, GuardEffectSettings, settings_from_legacy_gift,
},
i18n,
realtime::InMemoryComponentStore,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
};
const MAX_COMPONENT_INSTANCES_PER_KIND: i64 = 16;
#[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 an initial instance of each built-in
/// component. Registration normally creates these rows; this startup
/// backfill keeps upgrades from older releases complete. Additional
/// instances are created explicitly through the component API.
pub async fn ensure_builtin_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?;
transaction
.query_one(
"SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE",
&[&tenant.user_id],
)
.await?;
let existing = transaction
.query_opt(
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2 \
ORDER BY created_at,id LIMIT 1",
&[&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_opt(
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2 \
ORDER BY created_at,id LIMIT 1",
&[&tenant.user_id, &SONG_REQUEST_KIND],
)
.await?
.ok_or_else(|| {
RepositoryError::Invalid(
"failed to create the initial song request component".into(),
)
})?
.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?;
let has_gift_effect = transaction
.query_one(
"SELECT EXISTS(SELECT 1 FROM component_instances \
WHERE owner_user_id=$1 AND kind=$2)",
&[&tenant.user_id, &GIFT_EFFECT_KIND],
)
.await?
.get::<_, bool>(0);
if !has_gift_effect {
let gift_settings = serde_json::to_value(GiftEffectSettings::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)",
&[
&Uuid::new_v4(),
&tenant.user_id,
&GIFT_EFFECT_KIND,
&GIFT_EFFECT_NAME,
&gift_settings,
],
)
.await?;
}
let has_guard_effect = transaction
.query_one(
"SELECT EXISTS(SELECT 1 FROM component_instances \
WHERE owner_user_id=$1 AND kind=$2)",
&[&tenant.user_id, &GUARD_EFFECT_KIND],
)
.await?
.get::<_, bool>(0);
if !has_guard_effect {
let legacy_gift_settings = transaction
.query_opt(
"SELECT settings FROM component_instances \
WHERE owner_user_id=$1 AND kind=$2 ORDER BY created_at,id LIMIT 1",
&[&tenant.user_id, &GIFT_EFFECT_KIND],
)
.await?
.map(|row| row.get::<_, Value>(0));
let guard_settings = legacy_gift_settings
.as_ref()
.map(settings_from_legacy_gift)
.unwrap_or_else(GuardEffectSettings::default);
let guard_settings = serde_json::to_value(guard_settings)
.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)",
&[
&Uuid::new_v4(),
&tenant.user_id,
&GUARD_EFFECT_KIND,
&GUARD_EFFECT_NAME,
&guard_settings,
],
)
.await?;
}
let has_gift_menu = transaction
.query_one(
"SELECT EXISTS(SELECT 1 FROM component_instances \
WHERE owner_user_id=$1 AND kind=$2)",
&[&tenant.user_id, &GIFT_MENU_KIND],
)
.await?
.get::<_, bool>(0);
if !has_gift_menu {
let menu_settings = serde_json::to_value(GiftMenuSettings::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)",
&[
&Uuid::new_v4(),
&tenant.user_id,
&GIFT_MENU_KIND,
&GIFT_MENU_NAME,
&menu_settings,
],
)
.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> {
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?;
// Serialize instance-count checks for this account. Without the owner
// row lock, concurrent requests could both pass the per-kind limit.
transaction
.query_one(
"SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE",
&[&owner_id],
)
.await?;
let instance_count = transaction
.query_one(
"SELECT count(*) FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
&[&owner_id, &component.kind],
)
.await?
.get::<_, i64>(0);
if instance_count >= MAX_COMPONENT_INSTANCES_PER_KIND {
return Err(RepositoryError::Invalid(format!(
"a component kind accepts at most {MAX_COMPONENT_INSTANCES_PER_KIND} instances"
)));
}
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?;
if component.kind == SONG_REQUEST_KIND {
transaction
.execute(
"INSERT INTO song_request_state(owner_user_id,component_instance_id) \
VALUES($1,$2)",
&[&owner_id, &component.id],
)
.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?;
transaction
.query_one(
"SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE",
&[&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);
let instance_count = transaction
.query_one(
"SELECT count(*) FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
&[&owner_id, &kind],
)
.await?
.get::<_, i64>(0);
if instance_count <= 1 {
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 account_language(&self, owner_id: Uuid) -> Result<String, RepositoryError> {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, owner_id).await?;
let language = transaction
.query_opt(
"SELECT language FROM account_preferences WHERE user_id=$1",
&[&owner_id],
)
.await?
.map(|row| row.get(0))
.unwrap_or_else(|| i18n::default_language().to_owned());
transaction.commit().await?;
Ok(language)
}
pub async fn set_account_language(
&self,
owner_id: Uuid,
language: &str,
) -> Result<String, RepositoryError> {
if !i18n::is_supported(language) {
return Err(RepositoryError::Invalid("language is not supported".into()));
}
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, owner_id).await?;
transaction
.execute(
"INSERT INTO account_preferences(user_id,language) VALUES($1,$2) \
ON CONFLICT(user_id) DO UPDATE SET language=EXCLUDED.language,updated_at=now()",
&[&owner_id, &language],
)
.await?;
transaction.commit().await?;
Ok(language.to_owned())
}
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)
}
}