proper productionize project
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
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,
|
||||
};
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
||||
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?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
(id,owner_user_id,source_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,true)",
|
||||
&[
|
||||
&component.id,
|
||||
&owner_id,
|
||||
&source_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 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 id,source_id,kind,name,settings,settings_version,enabled \
|
||||
FROM component_instances WHERE owner_user_id=$1 AND 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,
|
||||
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,
|
||||
source_id: record.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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user