use crate::Role;
use crate::Workspace;
use crate::WorkspaceStatus;
use crate::git_commands::run_git_output;
use crate::turso::{self, TxGuard};
use anyhow::{Context, Result};
use serde::Serialize;
use std::path::{Path, PathBuf};
use strum::IntoEnumIterator;
use tracing::warn;
crate::define_store! {
pub(crate) static USER_STORE: UserStore,
db_name = "users",
schema = SCHEMA,
post_open = ensure_admin_user,
expect = "USER_STORE not initialized — call init_global() first",
}
const SCHEMA: &str = "\
CREATE TABLE IF NOT EXISTS users (
name TEXT PRIMARY KEY,
permissions TEXT,
selected_workspace TEXT,
selected_role TEXT
);
CREATE TABLE IF NOT EXISTS user_channels (
user_name TEXT NOT NULL REFERENCES users(name),
channel TEXT NOT NULL,
identifier TEXT NOT NULL,
reply_target TEXT,
UNIQUE(channel, identifier)
);
CREATE TABLE IF NOT EXISTS user_roles (
user_name TEXT NOT NULL REFERENCES users(name),
role TEXT NOT NULL,
PRIMARY KEY (user_name, role)
);";
crate::columns! {
USERS_COLUMNS [USERS] {
NAME => "name",
PERMISSIONS => "permissions",
SELECTED_WORKSPACE => "selected_workspace",
SELECTED_ROLE => "selected_role",
}
}
crate::columns! {
USER_CHANNEL_COLUMNS [UC] {
CHANNEL => "channel",
IDENTIFIER => "identifier",
REPLY_TARGET => "reply_target",
}
}
impl UserStore {
async fn ensure_admin_user(&self) -> Result<()> {
let rows = self
.conn
.query("SELECT 1 FROM users WHERE name = 'admin'", turso::params![])
.await?;
if rows.is_empty() {
let mut roles = vec![Role::Analyst];
roles.extend(Role::iter().filter(|r| *r != Role::Analyst));
self.add_user("admin", Some("full"), &roles).await?;
}
Ok(())
}
pub async fn add_user(
&self,
name: &str,
permissions: Option<&str>,
roles: &[Role],
) -> Result<()> {
let inserted = self
.conn
.execute(
"INSERT OR IGNORE INTO users (name, permissions) \
VALUES (?1, ?2)",
turso::params![name, permissions],
)
.await?;
let tx = self.conn.begin_tx().await?;
for role in roles {
tx.execute(
"INSERT OR IGNORE INTO user_roles (user_name, role) VALUES (?1, ?2)",
turso::params![name, role.as_str()],
)
.await?;
}
if inserted > 0
&& let Some(first) = roles.first()
{
tx.execute(
"UPDATE users SET selected_role = ?1 WHERE name = ?2",
turso::params![first.as_str(), name],
)
.await?;
}
tx.commit().await?;
init_personal_workspace_dir(name).await;
Ok(())
}
pub async fn set_user_roles(&self, name: &str, roles: &[Role]) -> Result<()> {
let tx = self.conn.begin_tx().await?;
tx.execute(
"DELETE FROM user_roles WHERE user_name = ?1",
turso::params![name],
)
.await?;
for role in roles {
tx.execute(
"INSERT INTO user_roles (user_name, role) VALUES (?1, ?2)",
turso::params![name, role.as_str()],
)
.await?;
}
let selected: Option<String> = tx
.query_row(
"SELECT selected_role FROM users WHERE name = ?1",
turso::params![name],
|row| row.get::<Option<String>>(0),
)
.await
.context("Failed to read selected_role while updating role pool")?;
match selected {
Some(cur) if roles.iter().any(|r| r.as_str() == cur) => {}
Some(_) => {
let fallback = roles.first().map(|r| r.as_str().to_string());
tx.execute(
"UPDATE users SET selected_role = ?1 WHERE name = ?2",
turso::params![fallback, name],
)
.await?;
}
None => {}
}
tx.commit().await?;
Ok(())
}
pub async fn get_user_roles(&self, user_name: &str) -> Result<Vec<Role>> {
let rows = self
.conn
.query_map_strict(
"SELECT role FROM user_roles WHERE user_name = ?1",
turso::params![user_name],
|row| row.get::<String>(0),
)
.await?;
let parsed: Vec<Role> = rows.iter().filter_map(|s| s.parse::<Role>().ok()).collect();
Ok(Role::iter().filter(|r| parsed.contains(r)).collect())
}
pub async fn delete_user(&self, name: &str) -> Result<()> {
let tx = self.conn.begin_tx().await?;
tx.execute(
"DELETE FROM user_roles WHERE user_name = ?1",
turso::params![name],
)
.await?;
tx.execute(
"DELETE FROM user_channels WHERE user_name = ?1",
turso::params![name],
)
.await?;
tx.execute("DELETE FROM users WHERE name = ?1", turso::params![name])
.await?;
tx.commit().await?;
Ok(())
}
async fn user_column(&self, column: &str, user_name: &str) -> Result<Option<String>> {
self.conn
.query_optional(
&format!("SELECT {column} FROM users WHERE name = ?1"),
turso::params![user_name],
|row| row.get::<Option<String>>(0),
)
.await
.map(Option::flatten)
}
pub async fn get_selected_workspace_name(&self, user_name: &str) -> Result<Option<String>> {
self.user_column("selected_workspace", user_name).await
}
pub async fn get_active_role(&self, user_name: &str) -> Result<Option<String>> {
self.user_column("selected_role", user_name).await
}
pub async fn get_permissions(&self, user_name: &str) -> Result<Option<String>> {
self.user_column("permissions", user_name).await
}
pub async fn resolve_user_by_reply_target(
&self,
channel: &str,
target: &str,
) -> Result<Option<String>> {
let rows = self
.conn
.query(
"SELECT user_name, reply_target FROM user_channels WHERE channel = ?1",
turso::params![channel],
)
.await?;
let thread_prefix = format!("{target}:");
for row in rows {
let user_name: String = row.get(0)?;
let reply_target: Option<String> = row.get(1)?;
if let Some(t) = reply_target
&& (t == target || t.starts_with(&thread_prefix))
{
return Ok(Some(user_name));
}
}
Ok(None)
}
pub async fn bind_channel(
&self,
user_name: &str,
channel: &str,
identifier: &str,
) -> Result<()> {
self.conn
.execute(
"INSERT OR REPLACE INTO user_channels (user_name, channel, identifier) \
VALUES (?1, ?2, ?3)",
turso::params![user_name, channel, identifier],
)
.await?;
Ok(())
}
pub async fn unbind_channel(
&self,
user_name: &str,
channel: &str,
identifier: &str,
) -> Result<()> {
self.conn
.execute(
"DELETE FROM user_channels WHERE user_name = ?1 AND channel = ?2 AND identifier = ?3",
turso::params![user_name, channel, identifier],
)
.await?;
Ok(())
}
pub async fn update_channel_contact(
&self,
channel: &str,
identifier: &str,
reply_target: &str,
) -> Result<()> {
self.conn
.execute(
"UPDATE user_channels SET reply_target = ?1 \
WHERE channel = ?2 AND identifier = ?3",
turso::params![reply_target, channel, identifier],
)
.await?;
Ok(())
}
pub async fn resolve_user_by_channel(
&self,
channel: &str,
identifier: &str,
) -> Result<Option<String>> {
self.conn
.query_optional(
"SELECT user_name FROM user_channels WHERE channel = ?1 AND identifier = ?2",
turso::params![channel, identifier],
|row| row.get::<String>(0),
)
.await
}
pub async fn get_user_channels(&self, user_name: &str) -> Result<Vec<ChannelBinding>> {
self.conn
.query_map_strict(
&format!("SELECT {USER_CHANNEL_COLUMNS} FROM user_channels WHERE user_name = ?1"),
turso::params![user_name],
|row| {
Ok::<_, ::turso::Error>(ChannelBinding {
channel: row.get::<String>(COL_UC_CHANNEL)?,
identifier: row.get::<String>(COL_UC_IDENTIFIER)?,
reply_target: row.get::<Option<String>>(COL_UC_REPLY_TARGET)?,
})
},
)
.await
}
async fn user_record_from_row(&self, row: &turso::Row) -> Result<UserRecord> {
let name: String = row.get(COL_USERS_NAME)?;
let roles = self.get_user_roles(&name).await.unwrap_or_default();
Ok(UserRecord {
name: name.clone(),
permissions: row.get::<Option<String>>(COL_USERS_PERMISSIONS)?,
selected_workspace: row.get::<Option<String>>(COL_USERS_SELECTED_WORKSPACE)?,
selected_role: row.get::<Option<String>>(COL_USERS_SELECTED_ROLE)?,
roles: roles.iter().map(|r| r.as_str().to_string()).collect(),
channels: self.get_user_channels(&name).await.unwrap_or_default(),
})
}
async fn list_users_where(
&self,
suffix: &str,
params: impl turso::IntoParams + Send + 'static,
) -> Result<Vec<UserRecord>> {
let sql = format!("SELECT {USERS_COLUMNS} FROM users {suffix}");
let rows = self.conn.query(&sql, params).await?;
let mut users = Vec::with_capacity(rows.len());
for row in rows {
users.push(self.user_record_from_row(&row).await?);
}
Ok(users)
}
pub async fn find_by_workspace(&self, workspace_name: &str) -> Result<Vec<UserRecord>> {
self.list_users_where(
"WHERE selected_workspace = ?1",
turso::params![workspace_name],
)
.await
}
pub async fn find_by_name(&self, user_name: &str) -> Result<Option<UserRecord>> {
let rows = self
.conn
.query(
&format!("SELECT {USERS_COLUMNS} FROM users WHERE name = ?1"),
turso::params![user_name],
)
.await?;
match rows.into_iter().next() {
Some(row) => Ok(Some(self.user_record_from_row(&row).await?)),
None => Ok(None),
}
}
pub async fn list_users(&self) -> Result<Vec<UserRecord>> {
self.list_users_where("", turso::params![]).await
}
pub async fn find_admin(&self) -> Result<Option<UserRecord>> {
self.list_users_where("WHERE permissions = ?1", turso::params!["full"])
.await
.map(|users| users.into_iter().next())
}
pub async fn update_user(
&self,
name: &str,
role_name: FieldUpdate<'_>,
workspace_name: FieldUpdate<'_>,
permissions: FieldUpdate<'_>,
) -> Result<()> {
let tx = self.conn.begin_tx().await?;
upsert_user_column(&tx, name, "selected_role", role_name).await?;
upsert_user_column(&tx, name, "selected_workspace", workspace_name).await?;
upsert_user_column(&tx, name, "permissions", permissions).await?;
tx.commit().await?;
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
pub enum FieldUpdate<'a> {
Unchanged,
Clear,
Set(&'a str),
}
async fn upsert_user_column(
tx: &TxGuard<'_>,
name: &str,
field: &str,
value: FieldUpdate<'_>,
) -> Result<()> {
let val: Option<&str> = match value {
FieldUpdate::Unchanged => return Ok(()),
FieldUpdate::Clear => None,
FieldUpdate::Set(v) => Some(v),
};
let sql = format!(
"INSERT INTO users (name, {field}) VALUES (?1, ?2) \
ON CONFLICT(name) DO UPDATE SET {field} = excluded.{field}"
);
tx.execute(&sql, turso::params![name, val]).await?;
Ok(())
}
#[derive(Debug, Clone, Serialize)]
pub struct UserRecord {
pub name: String,
pub permissions: Option<String>,
pub selected_workspace: Option<String>,
pub selected_role: Option<String>,
pub roles: Vec<String>,
pub channels: Vec<ChannelBinding>,
}
impl UserRecord {
#[must_use]
pub fn is_admin(&self) -> bool {
is_admin_permissions(self.permissions.as_deref())
}
}
#[must_use]
pub fn is_admin_permissions(permissions: Option<&str>) -> bool {
permissions == Some("full")
}
#[derive(Debug, Clone, Serialize)]
pub struct ChannelBinding {
pub channel: String,
pub identifier: String,
pub reply_target: Option<String>,
}
#[must_use]
pub(crate) fn userspaces_root() -> PathBuf {
let storage_root = crate::config::CONFIG
.try_storage_root()
.unwrap_or_else(fallback_storage_root);
storage_root.join("userspaces")
}
#[cfg(test)]
fn fallback_storage_root() -> PathBuf {
crate::util::test::test_root().clone()
}
#[cfg(not(test))]
fn fallback_storage_root() -> PathBuf {
crate::config::default_config_dir()
.unwrap_or_else(|_| std::env::temp_dir().join("mahbot_userspaces"))
}
#[must_use]
pub fn personal_workspace_path(user_name: &str) -> PathBuf {
userspaces_root().join(user_name)
}
async fn init_personal_workspace_dir(name: &str) {
let path = personal_workspace_path(name);
if let Err(e) = tokio::fs::create_dir_all(&path).await {
warn!(
path = %path.display(),
error = %e,
"Failed to create personal workspace directory"
);
}
match run_git_output(&path, &["init", "-q"]).await {
Ok(o) if o.status.success() => {}
Ok(_) => warn!(
path = %path.display(),
"git init failed for personal workspace (git may not be installed)"
),
Err(e) => warn!(
path = %path.display(),
error = %e,
"git init failed for personal workspace"
),
}
}
pub async fn get_raw_selected_workspace(user_name: &str) -> Result<Option<String>> {
store().get_selected_workspace_name(user_name).await
}
pub async fn get_workspace(user_name: &str) -> Result<Option<Workspace>> {
let s = store();
let selected = s.get_selected_workspace_name(user_name).await?;
if let Some(ws_name) = selected {
crate::workspace::get_by_name(&ws_name).await
} else {
let path = personal_workspace_path(user_name);
Ok(Some(personal_workspace_struct(user_name, &path)))
}
}
#[must_use]
pub fn personal_workspace_struct(user_name: &str, path: &Path) -> Workspace {
let mut ws = Workspace::from_path(path);
ws.name = format!("personal:{user_name}");
ws.status = WorkspaceStatus::Ready;
ws.maintainer_debounce_mins = Workspace::MAX_MAINTAINER_DEBOUNCE_MINS;
ws
}
pub async fn resolve_workspace_for_user_name(user_name: &str) -> Workspace {
match get_workspace(user_name).await {
Ok(Some(ws)) => ws,
Ok(None) => {
warn!(
user_name = %user_name,
"workspace resolution: selected_workspace points to non-existent workspace; \
falling back to personal workspace",
);
let path = personal_workspace_path(user_name);
personal_workspace_struct(user_name, &path)
}
Err(e) => {
warn!(
user_name = %user_name,
error = %e,
"workspace resolution: database error; falling back to personal workspace",
);
let path = personal_workspace_path(user_name);
personal_workspace_struct(user_name, &path)
}
}
}
pub async fn role_pool_status(user_name: &str) -> (Vec<Role>, bool) {
match store().get_user_roles(user_name).await {
Ok(pool) => (pool, false),
Err(e) => {
tracing::warn!(error = %e, user_name, "Failed to read role pool");
(Vec::new(), true)
}
}
}
pub async fn role_pool(user_name: &str) -> Vec<Role> {
role_pool_status(user_name).await.0
}
pub async fn switch_active_role(user_name: &str, role: Role) -> Result<()> {
store()
.update_user(
user_name,
FieldUpdate::Set(role.as_str()),
FieldUpdate::Unchanged,
FieldUpdate::Unchanged,
)
.await
}
pub async fn resolve_active_role(user_name: &str) -> Option<Role> {
let pool = role_pool(user_name).await;
resolve_active_role_from_pool(user_name, &pool).await
}
pub async fn resolve_active_role_from_pool(user_name: &str, pool: &[Role]) -> Option<Role> {
if pool.is_empty() {
return None;
}
let selected = match store().get_active_role(user_name).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, user_name, "Failed to read selected role");
return None;
}
};
match selected {
Some(name) => name
.parse::<Role>()
.ok()
.filter(|r| pool.contains(r))
.or_else(|| pool.first().copied()),
None if pool.contains(&Role::Analyst) => Some(Role::Analyst),
None => pool.first().copied(),
}
}
#[must_use]
fn resolve_effective_role(role: Role, ws_name: &str, pool: &[Role]) -> Role {
if role == Role::Manager && is_personal_workspace(ws_name) {
if pool.contains(&Role::Analyst) {
Role::Analyst
} else {
role
}
} else {
role
}
}
#[must_use]
fn pins_to_personal(role: Role, ws_name: &str, user_name: &str) -> bool {
!user_name.is_empty()
&& (role == Role::Assistant || role == Role::Artist)
&& !is_personal_workspace(ws_name)
}
#[must_use]
pub(crate) fn effective_workspace_for_role(
role: Role,
ws: Workspace,
user_name: &str,
) -> Workspace {
if pins_to_personal(role, &ws.name, user_name) {
let path = personal_workspace_path(user_name);
personal_workspace_struct(user_name, &path)
} else {
ws
}
}
#[must_use]
pub fn effective_role_and_workspace(
role: Role,
ws: Workspace,
user_name: &str,
pool: &[Role],
) -> (Role, Workspace) {
let role = resolve_effective_role(role, &ws.name, pool);
let ws = effective_workspace_for_role(role, ws, user_name);
(role, ws)
}
pub async fn resolve_session_target(user_name: &str) -> (Role, Workspace) {
let (ws, pool) = tokio::join!(
resolve_workspace_for_user_name(user_name),
role_pool(user_name),
);
let role = resolve_active_role_from_pool(user_name, &pool)
.await
.unwrap_or(Role::Analyst);
effective_role_and_workspace(role, ws, user_name, &pool)
}
pub async fn resolve_user_by_channel(channel: &str, identifier: &str) -> Option<String> {
let store = USER_STORE.get()?;
store
.resolve_user_by_channel(channel, identifier)
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, ?channel, ?identifier, "Failed to resolve user by channel");
None
})
}
pub async fn resolve_user_by_reply_target(channel: &str, target: &str) -> Option<String> {
let store = USER_STORE.get()?;
store
.resolve_user_by_reply_target(channel, target)
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, ?channel, ?target, "Failed to resolve user by reply target");
None
})
}
pub async fn is_admin(user_name: &str) -> bool {
match USER_STORE.get() {
Some(store) => match store.get_permissions(user_name).await {
Ok(perms) => is_admin_permissions(perms.as_deref()),
Err(e) => {
tracing::warn!(error = %e, user_name, "Failed to read permissions");
false
}
},
None => false,
}
}
pub async fn update_channel_contact(
channel: &str,
identifier: &str,
reply_target: &str,
) -> Result<()> {
store()
.update_channel_contact(channel, identifier, reply_target)
.await
}
#[must_use]
pub fn personal_user_name(workspace_name: &str) -> Option<&str> {
workspace_name.strip_prefix("personal:")
}
#[must_use]
pub fn is_personal_workspace(workspace_name: &str) -> bool {
personal_user_name(workspace_name).is_some()
}
#[cfg(test)]
pub(crate) mod test_util {
use super::*;
pub(crate) async fn init_test_store() {
crate::util::test::init_test_stores().await;
if let Some(store) = USER_STORE.get() {
let all_roles = Role::iter().collect::<Vec<_>>();
store
.add_user("alice", Some("full"), &all_roles)
.await
.expect("failed to add alice to test USER_STORE");
store
.add_user("bob", None, &all_roles)
.await
.expect("failed to add bob to test USER_STORE");
store
.bind_channel("alice", "telegram", "alice")
.await
.expect("failed to bind alice telegram");
store
.bind_channel("bob", "telegram", "bob")
.await
.expect("failed to bind bob telegram");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn role_pool_lifecycle() {
crate::util::test::init_test_stores().await;
let store = store();
store
.add_user("pool_user", None, &[Role::Analyst, Role::Coder])
.await
.unwrap();
assert_eq!(resolve_active_role("pool_user").await, Some(Role::Analyst));
store
.set_user_roles("pool_user", &[Role::Coder])
.await
.unwrap();
assert_eq!(resolve_active_role("pool_user").await, Some(Role::Coder));
store
.update_user(
"pool_user",
FieldUpdate::Set("engineer"),
FieldUpdate::Unchanged,
FieldUpdate::Unchanged,
)
.await
.unwrap();
assert_eq!(resolve_active_role("pool_user").await, Some(Role::Coder));
store.set_user_roles("pool_user", &[]).await.unwrap();
assert_eq!(resolve_active_role("pool_user").await, None);
store
.set_user_roles("pool_user", &[Role::Analyst])
.await
.unwrap();
assert_eq!(resolve_active_role("pool_user").await, Some(Role::Analyst));
}
#[tokio::test]
async fn delete_user_removes_role_pool_rows() {
crate::util::test::init_test_stores().await;
let store = store();
store
.add_user("doomed", None, &[Role::Analyst, Role::Coder])
.await
.unwrap();
store.delete_user("doomed").await.unwrap();
assert!(
store
.conn
.query(
"SELECT 1 FROM user_roles WHERE user_name = 'doomed'",
crate::turso::params![],
)
.await
.unwrap()
.is_empty(),
"user_roles rows must be deleted with the user"
);
assert_eq!(
store.get_user_roles("doomed").await.unwrap(),
Vec::<Role>::new()
);
}
#[test]
fn pinning_helpers_pin_assistant_artist_to_personal() {
assert!(pins_to_personal(Role::Assistant, "ws1", "alice"));
assert!(pins_to_personal(Role::Artist, "ws1", "alice"));
assert!(!pins_to_personal(
Role::Assistant,
"personal:alice",
"alice"
));
assert!(!pins_to_personal(Role::Manager, "ws1", "alice"));
assert!(!pins_to_personal(Role::Assistant, "ws1", ""));
let project = Workspace {
name: "ws1".to_string(),
..Default::default()
};
let personal = effective_workspace_for_role(Role::Assistant, project.clone(), "alice");
assert_eq!(personal.name, "personal:alice");
assert!(
personal.path.ends_with("userspaces/alice"),
"the personal workspace must use the userspaces path, got: {}",
personal.path
);
let kept = effective_workspace_for_role(Role::Manager, project.clone(), "alice");
assert_eq!(kept.name, "ws1");
let already = effective_workspace_for_role(Role::Artist, personal.clone(), "alice");
assert_eq!(already.name, "personal:alice");
let (role, ws) = effective_role_and_workspace(
Role::Manager,
Workspace {
name: "personal:alice".to_string(),
..Default::default()
},
"alice",
&[Role::Analyst],
);
assert_eq!(role, Role::Analyst);
assert_eq!(ws.name, "personal:alice");
}
#[tokio::test]
async fn resolve_session_target_matches_routing() {
crate::util::test::init_test_stores().await;
let user = "home_clear_target";
let store = store();
store
.add_user(
user,
Some("full"),
&[Role::Manager, Role::Assistant, Role::Analyst],
)
.await
.unwrap();
crate::util::test::create_test_workspace(
"/tmp/home_clear_target_ws",
"ws_home_clear_target",
)
.await;
store
.update_user(
user,
FieldUpdate::Set("manager"),
FieldUpdate::Set("ws_home_clear_target"),
FieldUpdate::Unchanged,
)
.await
.unwrap();
let (role, ws) = resolve_session_target(user).await;
assert_eq!(role, Role::Manager);
assert_eq!(ws.name, "ws_home_clear_target");
store
.update_user(
user,
FieldUpdate::Set("assistant"),
FieldUpdate::Unchanged,
FieldUpdate::Unchanged,
)
.await
.unwrap();
let (role, ws) = resolve_session_target(user).await;
assert_eq!(role, Role::Assistant);
assert_eq!(ws.name, "personal:home_clear_target");
store
.update_user(
user,
FieldUpdate::Set("manager"),
FieldUpdate::Clear,
FieldUpdate::Unchanged,
)
.await
.unwrap();
let (role, ws) = resolve_session_target(user).await;
assert_eq!(role, Role::Analyst);
assert_eq!(ws.name, "personal:home_clear_target");
}
}