534 lines
18 KiB
Rust
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(¤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<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)
|
|
}
|
|
}
|