feat: init

This commit is contained in:
zhenyi
2026-06-07 11:30:56 +08:00
commit 563381c1ca
361 changed files with 41327 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
/target
.idea
.codegraph
.claude
AGENT.md
CLAUDE.md
+10
View File
@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
Generated
+5360
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
[package]
name = "appks"
version = "0.1.0"
edition = "2024"
[lib]
name = "appks"
path = "lib.rs"
[[bin]]
name = "appks"
path = "main.rs"
[dependencies]
sqlx = { version = "0.9.0", features = ["postgres","runtime-tokio","chrono","uuid","json"] }
tokio = { version = "1.52.3", features = ["full"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = [] }
chrono = { version = "0.4.19", features = ["serde"] }
uuid = { version = "1.23.1", features = ["serde","v4","v7"] }
reqwest = { version = "0.13.4", features = ["json"] }
tracing = { version = "0.1.44", features = [] }
tracing-subscriber = { version = "0.3.23", features = ["fmt"] }
dotenvy = "0.15.7"
thiserror = "2"
redis = { version = "1.2.1", features = ["cluster","cluster-async","aio","tokio-comp","r2d2"] }
r2d2 = { version = "0.8.10", features = [] }
dashmap = "6.1"
object_store = { version = "0.13.2", features = ["tokio","aws","cloud"] }
argon2 = "0.5"
rsa = "0.9"
chacha20poly1305 = "0.10"
hkdf = "0.12"
sha2 = "0.10"
sha1 = "0.10"
hmac = "0.12"
base64 = "0.22"
rand = "0.8"
captcha-rs = "0.5"
tonic = { version = "0.14.6", features = ["transport", "channel"] }
prost = "0.14.3"
prost-types = "0.14.3"
tonic-prost = "0.14.6"
url = "2.5"
etcd-client = "0.14"
tokio-stream = "0.1"
async-nats = "0.49"
futures-util = "0.3"
utoipa = { version = "5.5.0", features = ["uuid","chrono","auto_into_responses","actix_extras","decimal","macros"]}
[build-dependencies]
tonic-prost-build = "0.14.6"
+29
View File
@@ -0,0 +1,29 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_prost_build::configure()
.build_client(true)
.build_server(false)
.compile_protos(&["proto/email/email.proto"], &["proto/email"])?;
tonic_prost_build::configure()
.build_client(true)
.build_server(false)
.compile_protos(
&[
"proto/git/oid.proto",
"proto/git/tagger.proto",
"proto/git/repository.proto",
"proto/git/commit.proto",
"proto/git/branch.proto",
"proto/git/tag.proto",
"proto/git/tree.proto",
"proto/git/diff.proto",
"proto/git/merge.proto",
"proto/git/blame.proto",
"proto/git/archive.proto",
"proto/git/pack.proto",
],
&["proto/git"],
)?;
Ok(())
}
+304
View File
@@ -0,0 +1,304 @@
use dashmap::DashMap;
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Mutex;
use std::time::{Duration, Instant};
struct CacheEntry<V> {
value: V,
expires_at: Instant,
}
struct LruNode<K> {
key: Option<K>,
prev: usize,
next: usize,
}
struct LruTracker<K> {
nodes: Vec<LruNode<K>>,
key_to_idx: HashMap<K, usize>,
head: usize,
tail: usize,
}
impl<K: Eq + Hash + Clone> LruTracker<K> {
fn new() -> Self {
let sentinel = LruNode {
key: None,
prev: 0,
next: 0,
};
Self {
nodes: vec![sentinel],
key_to_idx: HashMap::new(),
head: 0,
tail: 0,
}
}
fn touch(&mut self, key: &K) {
if let Some(&idx) = self.key_to_idx.get(key) {
self.detach(idx);
self.attach_front(idx);
}
}
fn push_front(&mut self, key: K) -> usize {
let idx = self.nodes.len();
self.nodes.push(LruNode {
key: Some(key.clone()),
prev: 0,
next: 0,
});
self.key_to_idx.insert(key, idx);
self.attach_front(idx);
idx
}
fn pop_back(&mut self) -> Option<K> {
if self.tail == 0 {
return None;
}
let lru = self.tail;
let key = self.nodes[lru].key.take();
self.detach(lru);
if let Some(ref k) = key {
self.key_to_idx.remove(k);
}
key
}
fn remove(&mut self, key: &K) {
if let Some(&idx) = self.key_to_idx.get(key) {
self.detach(idx);
self.key_to_idx.remove(key);
}
}
fn clear(&mut self) {
self.key_to_idx.clear();
self.nodes.truncate(1);
self.head = 0;
self.tail = 0;
}
fn len(&self) -> usize {
self.key_to_idx.len()
}
fn detach(&mut self, idx: usize) {
let prev = self.nodes[idx].prev;
let next = self.nodes[idx].next;
if prev != 0 {
self.nodes[prev].next = next;
} else {
self.head = next;
}
if next != 0 {
self.nodes[next].prev = prev;
} else {
self.tail = prev;
}
}
fn attach_front(&mut self, idx: usize) {
self.nodes[idx].prev = 0;
self.nodes[idx].next = self.head;
if self.head != 0 {
self.nodes[self.head].prev = idx;
} else {
self.tail = idx;
}
self.head = idx;
}
}
pub struct LruTtlCache<K, V> {
map: DashMap<K, CacheEntry<V>>,
lru: Mutex<LruTracker<K>>,
capacity: usize,
ttl: Duration,
}
impl<K: Eq + Hash + Clone, V: Clone> LruTtlCache<K, V> {
pub fn new(capacity: usize, ttl: Duration) -> Self {
Self {
map: DashMap::with_capacity(capacity),
lru: Mutex::new(LruTracker::new()),
capacity,
ttl,
}
}
pub fn get(&self, key: &K) -> Option<V> {
let entry = self.map.get(key)?;
let expired = entry.expires_at <= Instant::now();
let value = entry.value.clone();
drop(entry);
if expired {
self.remove(key);
return None;
}
if let Ok(mut lru) = self.lru.lock() {
lru.touch(key);
}
Some(value)
}
pub fn insert(&self, key: K, value: V) {
self.insert_with_ttl(key, value, self.ttl);
}
pub fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) {
let now = Instant::now();
if self.map.contains_key(&key) {
self.map.insert(
key.clone(),
CacheEntry {
value,
expires_at: now + ttl,
},
);
if let Ok(mut lru) = self.lru.lock() {
lru.touch(&key);
}
return;
}
let mut lru = self.lru.lock().unwrap();
if lru.len() >= self.capacity
&& let Some(evicted_key) = lru.pop_back()
{
self.map.remove(&evicted_key);
}
self.map.insert(
key.clone(),
CacheEntry {
value,
expires_at: now + ttl,
},
);
lru.push_front(key);
}
pub fn remove(&self, key: &K) -> Option<V> {
if let Ok(mut lru) = self.lru.lock() {
lru.remove(key);
}
self.map.remove(key).map(|(_, entry)| entry.value)
}
pub fn contains(&self, key: &K) -> bool {
self.map.contains_key(key)
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
pub fn clear(&self) {
self.map.clear();
if let Ok(mut lru) = self.lru.lock() {
lru.clear();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_insert_and_get() {
let cache = LruTtlCache::new(3, Duration::from_secs(60));
cache.insert("a", 1);
cache.insert("b", 2);
cache.insert("c", 3);
assert_eq!(cache.get(&"a"), Some(1));
assert_eq!(cache.get(&"b"), Some(2));
assert_eq!(cache.get(&"c"), Some(3));
}
#[test]
fn test_lru_eviction() {
let cache = LruTtlCache::new(2, Duration::from_secs(60));
cache.insert("a", 1);
cache.insert("b", 2);
cache.get(&"a");
cache.insert("c", 3);
assert_eq!(cache.get(&"a"), Some(1));
assert_eq!(cache.get(&"b"), None);
assert_eq!(cache.get(&"c"), Some(3));
}
#[test]
fn test_ttl_expiry() {
let cache = LruTtlCache::new(3, Duration::from_millis(10));
cache.insert("a", 1);
std::thread::sleep(Duration::from_millis(20));
assert_eq!(cache.get(&"a"), None);
}
#[test]
fn test_update_existing() {
let cache = LruTtlCache::new(3, Duration::from_secs(60));
cache.insert("a", 1);
cache.insert("a", 100);
assert_eq!(cache.get(&"a"), Some(100));
assert_eq!(cache.len(), 1);
}
#[test]
fn test_remove() {
let cache = LruTtlCache::new(3, Duration::from_secs(60));
cache.insert("a", 1);
cache.insert("b", 2);
assert_eq!(cache.remove(&"a"), Some(1));
assert_eq!(cache.get(&"a"), None);
assert_eq!(cache.len(), 1);
}
#[test]
fn test_concurrent_access() {
let cache = std::sync::Arc::new(LruTtlCache::new(10, Duration::from_secs(60)));
let c1 = cache.clone();
let c2 = cache.clone();
let t1 = std::thread::spawn(move || {
for i in 0..100 {
c1.insert(i, i * 2);
}
});
let t2 = std::thread::spawn(move || {
for i in 0..100 {
let _ = c2.get(&i);
}
});
t1.join().unwrap();
t2.join().unwrap();
assert!(cache.len() <= 10);
}
}
+97
View File
@@ -0,0 +1,97 @@
use crate::cache::lru::LruTtlCache;
use crate::cache::redis::AppRedis;
use crate::config::AppConfig;
use crate::error::AppResult;
use ::redis::Cmd;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::time::Duration;
pub mod lru;
pub mod redis;
pub struct AppCache {
l1: LruTtlCache<String, String>,
l2: AppRedis,
key_prefix: String,
default_ttl: Duration,
}
impl AppCache {
pub fn from_config(config: &AppConfig) -> AppResult<Self> {
let cap = config.lru_default_capacity()?;
let ttl = Duration::from_secs(config.lru_default_ttl_secs()?);
let l2 = AppRedis::from_config(config)?;
let key_prefix = config.redis_key_prefix()?;
Ok(Self {
l1: LruTtlCache::new(cap, ttl),
l2,
key_prefix,
default_ttl: ttl,
})
}
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
if let Some(json) = self.l1.get(&key.to_string()) {
return serde_json::from_str(&json).ok();
}
let full_key = self.full_key(key);
let mut conn = self.l2.get_connection().ok()?;
let json: String = Cmd::new()
.arg("GET")
.arg(&full_key)
.query::<Option<String>>(&mut *conn.inner_mut())
.ok()??;
let value: T = serde_json::from_str(&json).ok()?;
self.l1.insert(key.to_string(), json);
Some(value)
}
pub fn set<T: Serialize>(&self, key: &str, value: &T, ttl: Option<Duration>) -> AppResult<()> {
let json = serde_json::to_string(value)?;
let full_key = self.full_key(key);
let ttl_duration = ttl.unwrap_or(self.default_ttl);
let ttl_secs = ttl_duration.as_secs() as usize;
let mut conn = self.l2.get_connection()?;
Cmd::new()
.arg("SETEX")
.arg(&full_key)
.arg(ttl_secs)
.arg(&json)
.query::<()>(&mut *conn.inner_mut())?;
self.l1.insert_with_ttl(key.to_string(), json, ttl_duration);
Ok(())
}
pub fn delete(&self, key: &str) -> AppResult<()> {
self.l1.remove(&key.to_string());
let full_key = self.full_key(key);
let mut conn = self.l2.get_connection()?;
Cmd::new()
.arg("DEL")
.arg(&full_key)
.query::<()>(&mut *conn.inner_mut())?;
Ok(())
}
pub fn exists(&self, key: &str) -> bool {
if self.l1.get(&key.to_string()).is_some() {
return true;
}
let full_key = self.full_key(key);
if let Ok(mut conn) = self.l2.get_connection() {
return Cmd::new()
.arg("EXISTS")
.arg(&full_key)
.query(&mut *conn.inner_mut())
.unwrap_or(false);
}
false
}
fn full_key(&self, key: &str) -> String {
format!("{}{}", self.key_prefix, key)
}
}
+117
View File
@@ -0,0 +1,117 @@
use crate::config::AppConfig;
use crate::error::AppError;
use crate::error::AppResult;
use r2d2::Pool;
use redis::cluster::ClusterClient;
use redis::{Client, ConnectionLike, RedisError};
use std::time::Duration;
#[derive(Clone)]
enum RedisBackend {
Single(Pool<Client>),
Cluster(Pool<ClusterClient>),
}
#[derive(Clone)]
pub struct AppRedis {
backend: RedisBackend,
}
impl AppRedis {
pub fn from_config(config: &AppConfig) -> AppResult<Self> {
let backend = if config.redis_cluster_enabled()? {
let nodes = config.redis_cluster_nodes()?;
let cluster_client =
ClusterClient::new(nodes.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
let pool = Self::build_pool(config, cluster_client)?;
RedisBackend::Cluster(pool)
} else {
let url = config
.redis_url()?
.ok_or_else(|| AppError::Config("APP_REDIS_URL is not set".into()))?;
let client = Client::open(url.as_str())?;
let pool = Self::build_pool(config, client)?;
RedisBackend::Single(pool)
};
Ok(Self { backend })
}
fn build_pool<M: r2d2::ManageConnection>(config: &AppConfig, manager: M) -> AppResult<Pool<M>> {
let max_conn = config.redis_max_connections()?;
let min_conn = config.redis_min_connections()?;
let idle_timeout = config.redis_idle_timeout()?;
let conn_timeout = config.redis_connection_timeout()?;
Ok(r2d2::Builder::new()
.max_size(max_conn)
.min_idle(Some(min_conn))
.idle_timeout(Some(Duration::from_secs(idle_timeout)))
.connection_timeout(Duration::from_secs(conn_timeout))
.build(manager)?)
}
pub fn get_connection(&self) -> Result<PooledRedisConnection, r2d2::Error> {
match &self.backend {
RedisBackend::Single(pool) => pool.get().map(PooledRedisConnection::Single),
RedisBackend::Cluster(pool) => pool.get().map(PooledRedisConnection::Cluster),
}
}
}
#[allow(clippy::large_enum_variant)]
pub enum PooledRedisConnection {
Single(r2d2::PooledConnection<Client>),
Cluster(r2d2::PooledConnection<ClusterClient>),
}
impl PooledRedisConnection {
pub fn inner_mut(&mut self) -> &mut dyn ConnectionLike {
match self {
PooledRedisConnection::Single(conn) => conn,
PooledRedisConnection::Cluster(conn) => conn,
}
}
}
impl ConnectionLike for PooledRedisConnection {
fn req_packed_command(&mut self, cmd: &[u8]) -> Result<redis::Value, RedisError> {
match self {
PooledRedisConnection::Single(conn) => conn.req_packed_command(cmd),
PooledRedisConnection::Cluster(conn) => conn.req_packed_command(cmd),
}
}
fn req_packed_commands(
&mut self,
cmd: &[u8],
offset: usize,
count: usize,
) -> Result<Vec<redis::Value>, RedisError> {
match self {
PooledRedisConnection::Single(conn) => conn.req_packed_commands(cmd, offset, count),
PooledRedisConnection::Cluster(conn) => conn.req_packed_commands(cmd, offset, count),
}
}
fn get_db(&self) -> i64 {
match self {
PooledRedisConnection::Single(conn) => conn.get_db(),
PooledRedisConnection::Cluster(conn) => conn.get_db(),
}
}
fn check_connection(&mut self) -> bool {
match self {
PooledRedisConnection::Single(conn) => conn.check_connection(),
PooledRedisConnection::Cluster(conn) => conn.check_connection(),
}
}
fn is_open(&self) -> bool {
match self {
PooledRedisConnection::Single(conn) => conn.is_open(),
PooledRedisConnection::Cluster(conn) => conn.is_open(),
}
}
}
+12
View File
@@ -0,0 +1,12 @@
use crate::config::AppConfig;
use crate::error::AppResult;
impl AppConfig {
pub fn ai_provider_api_key(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_AI_PROVIDER_API_KEY")
}
pub fn ai_provider_url(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_AI_PROVIDER_URL")
}
}
+8
View File
@@ -0,0 +1,8 @@
use crate::config::AppConfig;
use crate::error::AppResult;
impl AppConfig {
pub fn app_url(&self) -> AppResult<String> {
self.get_env_or::<String>("APP_URL", "http://localhost:8000".into())
}
}
+32
View File
@@ -0,0 +1,32 @@
use crate::config::AppConfig;
use crate::error::AppResult;
impl AppConfig {
pub fn channel_ai_provider_api_key(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_CHANNEL_AI_PROVIDER_API_KEY")
}
pub fn channel_ai_provider_url(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_CHANNEL_AI_PROVIDER_URL")
}
pub fn channel_ai_provider_model(&self) -> AppResult<String> {
self.get_env_or("APP_CHANNEL_AI_PROVIDER_MODEL", "gpt-4o".to_string())
}
pub fn channel_ai_provider_temperature(&self) -> AppResult<f64> {
self.get_env_or("APP_CHANNEL_AI_PROVIDER_TEMPERATURE", 0.7)
}
pub fn channel_ai_provider_max_tokens(&self) -> AppResult<u32> {
self.get_env_or("APP_CHANNEL_AI_PROVIDER_MAX_TOKENS", 4096)
}
pub fn channel_ai_provider_top_p(&self) -> AppResult<f64> {
self.get_env_or("APP_CHANNEL_AI_PROVIDER_TOP_P", 1.0)
}
pub fn channel_ai_provider_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_CHANNEL_AI_PROVIDER_TIMEOUT", 60)
}
}
+87
View File
@@ -0,0 +1,87 @@
use crate::config::AppConfig;
use crate::error::{AppError, AppResult};
impl AppConfig {
pub fn database_url(&self) -> AppResult<String> {
if let Some(url) = self.get_env::<String>("APP_DATABASE_URL")? {
return Ok(url);
}
if let Some(url) = self.get_env::<String>("DATABASE_URL")? {
return Ok(url);
}
Err(AppError::Config(
"Neither APP_DATABASE_URL nor DATABASE_URL is set".into(),
))
}
pub fn database_max_connections(&self) -> AppResult<u32> {
self.get_env_or("APP_DATABASE_MAX_CONNECTIONS", 10)
}
pub fn database_min_connections(&self) -> AppResult<u32> {
self.get_env_or("APP_DATABASE_MIN_CONNECTIONS", 2)
}
pub fn database_idle_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_DATABASE_IDLE_TIMEOUT", 600)
}
pub fn database_max_lifetime(&self) -> AppResult<u64> {
self.get_env_or("APP_DATABASE_MAX_LIFETIME", 3600)
}
pub fn database_connection_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_DATABASE_CONNECTION_TIMEOUT", 8)
}
pub fn database_schema_search_path(&self) -> AppResult<String> {
self.get_env_or("APP_DATABASE_SCHEMA_SEARCH_PATH", "public".to_string())
}
pub fn database_read_write_split(&self) -> AppResult<bool> {
self.get_env_or("APP_DATABASE_READ_WRITE_SPLIT", false)
}
pub fn database_read_replicas(&self) -> AppResult<Vec<String>> {
match self.get_env::<String>("APP_DATABASE_REPLICAS")? {
Some(s) if !s.is_empty() => Ok(s
.split(',')
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())
.collect()),
_ => Ok(Vec::new()),
}
}
pub fn database_replica_max_connections(&self) -> AppResult<u32> {
self.get_env_or("APP_DATABASE_REPLICA_MAX_CONNECTIONS", 10)
}
pub fn database_replica_min_connections(&self) -> AppResult<u32> {
self.get_env_or("APP_DATABASE_REPLICA_MIN_CONNECTIONS", 2)
}
pub fn database_replica_idle_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_DATABASE_REPLICA_IDLE_TIMEOUT", 600)
}
pub fn database_replica_max_lifetime(&self) -> AppResult<u64> {
self.get_env_or("APP_DATABASE_REPLICA_MAX_LIFETIME", 3600)
}
pub fn database_replica_connection_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_DATABASE_REPLICA_CONNECTION_TIMEOUT", 8)
}
pub fn database_health_check_interval(&self) -> AppResult<u64> {
self.get_env_or("APP_DATABASE_HEALTH_CHECK_INTERVAL", 30)
}
pub fn database_retry_attempts(&self) -> AppResult<u32> {
self.get_env_or("APP_DATABASE_RETRY_ATTEMPTS", 3)
}
pub fn database_retry_delay(&self) -> AppResult<u64> {
self.get_env_or("APP_DATABASE_RETRY_DELAY", 5)
}
}
+27
View File
@@ -0,0 +1,27 @@
use crate::config::AppConfig;
use crate::error::AppResult;
impl AppConfig {
pub fn embed_ai_provider_api_key(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_EMBED_AI_PROVIDER_API_KEY")
}
pub fn embed_ai_provider_url(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_EMBED_AI_PROVIDER_URL")
}
pub fn embed_ai_provider_model(&self) -> AppResult<String> {
self.get_env_or(
"APP_EMBED_AI_PROVIDER_MODEL",
"text-embedding-3-small".to_string(),
)
}
pub fn embed_ai_provider_dimensions(&self) -> AppResult<u32> {
self.get_env_or("APP_EMBED_AI_PROVIDER_DIMENSIONS", 1536)
}
pub fn embed_ai_provider_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_EMBED_AI_PROVIDER_TIMEOUT", 30)
}
}
+59
View File
@@ -0,0 +1,59 @@
use crate::config::AppConfig;
use crate::error::AppResult;
impl AppConfig {
pub fn etcd_endpoints(&self) -> AppResult<Vec<String>> {
match self.get_env::<String>("APP_ETCD_ENDPOINTS")? {
Some(s) if !s.is_empty() => Ok(s
.split(',')
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())
.collect()),
_ => Ok(vec!["http://localhost:2379".to_string()]),
}
}
pub fn etcd_username(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_ETCD_USERNAME")
}
pub fn etcd_password(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_ETCD_PASSWORD")
}
pub fn etcd_ca_cert_path(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_ETCD_CA_CERT_PATH")
}
pub fn etcd_client_cert_path(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_ETCD_CLIENT_CERT_PATH")
}
pub fn etcd_client_key_path(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_ETCD_CLIENT_KEY_PATH")
}
pub fn etcd_key_prefix(&self) -> AppResult<String> {
self.get_env_or("APP_ETCD_KEY_PREFIX", "/appks/".to_string())
}
pub fn etcd_connect_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_ETCD_CONNECT_TIMEOUT", 5)
}
pub fn etcd_request_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_ETCD_REQUEST_TIMEOUT", 10)
}
pub fn etcd_keep_alive_interval(&self) -> AppResult<u64> {
self.get_env_or("APP_ETCD_KEEP_ALIVE_INTERVAL", 10)
}
pub fn etcd_lease_ttl(&self) -> AppResult<u64> {
self.get_env_or("APP_ETCD_LEASE_TTL", 15)
}
pub fn etcd_max_retries(&self) -> AppResult<u32> {
self.get_env_or("APP_ETCD_MAX_RETRIES", 3)
}
}
+16
View File
@@ -0,0 +1,16 @@
use crate::config::AppConfig;
use crate::error::AppResult;
impl AppConfig {
pub fn lru_default_capacity(&self) -> AppResult<usize> {
self.get_env_or("APP_LRU_DEFAULT_CAPACITY", 1000)
}
pub fn lru_default_ttl_secs(&self) -> AppResult<u64> {
self.get_env_or("APP_LRU_DEFAULT_TTL_SECS", 300)
}
pub fn lru_cleanup_interval_secs(&self) -> AppResult<u64> {
self.get_env_or("APP_LRU_CLEANUP_INTERVAL_SECS", 60)
}
}
+90
View File
@@ -0,0 +1,90 @@
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
use tokio::sync::OnceCell;
pub static GLOBAL_CONFIG: OnceCell<AppConfig> = OnceCell::const_new();
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AppConfig {
pub env: HashMap<String, String>,
}
impl AppConfig {
pub fn main_domain(&self) -> AppResult<String> {
self.get_env::<String>("APP_MAIN_DOMAIN")?
.filter(|s| !s.is_empty())
.ok_or_else(|| AppError::Config("APP_MAIN_DOMAIN is not set".into()))
}
pub const ENV_FILES: &'static [&'static str] = &[
".env",
".env.local",
".env.development",
".env.development.local",
".env.test",
".env.test.local",
".env.production",
".env.production.local",
];
pub fn get_env<T: FromStr>(&self, key: &str) -> AppResult<Option<T>>
where
<T as FromStr>::Err: std::fmt::Display,
{
match self.env.get(key) {
Some(v) if !v.is_empty() => Ok(Some(
v.parse::<T>().map_err(|e| AppError::Parse(e.to_string()))?,
)),
Some(_) => Ok(None),
None => Ok(None),
}
}
pub fn get_env_or<T: FromStr>(&self, key: &str, default: T) -> AppResult<T>
where
<T as FromStr>::Err: std::fmt::Display,
{
Ok(self.get_env(key)?.unwrap_or(default))
}
pub fn load() -> AppConfig {
let mut env = HashMap::new();
for env_file in AppConfig::ENV_FILES {
if let Err(e) = dotenvy::from_path(env_file) {
tracing::debug!(file = %env_file, error = %e, "dotenv load skipped");
}
if let Ok(env_file_content) = std::fs::read_to_string(env_file) {
for line in env_file_content.lines() {
if let Some((key, value)) = line.split_once('=') {
env.insert(key.to_string(), value.to_string());
}
}
}
}
env = env.into_iter().chain(std::env::vars()).collect();
let this = AppConfig { env };
if let Some(config) = GLOBAL_CONFIG.get() {
config.clone()
} else {
let _ = GLOBAL_CONFIG.set(this);
GLOBAL_CONFIG
.get()
.expect("global config should be set after load")
.clone()
}
}
}
pub mod aiprovider;
pub mod app;
pub mod channelaiprovider;
pub mod database;
pub mod embedaiprovider;
pub mod etcd;
pub mod lru;
pub mod nats;
pub mod qdrant;
pub mod redis;
pub mod rpc;
pub mod s3;
+54
View File
@@ -0,0 +1,54 @@
use crate::config::AppConfig;
use crate::error::{AppError, AppResult};
impl AppConfig {
pub fn nats_url(&self) -> AppResult<String> {
self.get_env::<String>("APP_NATS_URL")?
.filter(|s| !s.is_empty())
.ok_or_else(|| AppError::Config("APP_NATS_URL is not set".into()))
}
pub fn nats_username(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_NATS_USERNAME")
}
pub fn nats_password(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_NATS_PASSWORD")
}
pub fn nats_token(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_NATS_TOKEN")
}
pub fn nats_tls_enabled(&self) -> AppResult<bool> {
self.get_env_or("APP_NATS_TLS_ENABLED", false)
}
pub fn nats_connection_timeout_secs(&self) -> AppResult<u64> {
self.get_env_or("APP_NATS_CONNECTION_TIMEOUT", 5)
}
pub fn nats_ping_interval_secs(&self) -> AppResult<u64> {
self.get_env_or("APP_NATS_PING_INTERVAL", 20)
}
pub fn nats_reconnect_delay_secs(&self) -> AppResult<u64> {
self.get_env_or("APP_NATS_RECONNECT_DELAY", 2)
}
pub fn nats_max_reconnects(&self) -> AppResult<usize> {
self.get_env_or("APP_NATS_MAX_RECONNECTS", 60usize)
}
pub fn nats_stream_prefix(&self) -> AppResult<String> {
self.get_env_or("APP_NATS_STREAM_PREFIX", "APPKS".to_string())
}
pub fn nats_default_ack_wait_secs(&self) -> AppResult<u64> {
self.get_env_or("APP_NATS_ACK_WAIT_SECS", 30)
}
pub fn nats_default_max_deliver(&self) -> AppResult<i64> {
self.get_env_or("APP_NATS_MAX_DELIVER", 5i64)
}
}
+63
View File
@@ -0,0 +1,63 @@
use crate::config::AppConfig;
use crate::error::AppResult;
impl AppConfig {
pub fn qdrant_url(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_QDRANT_URL")
}
pub fn qdrant_cluster_nodes(&self) -> AppResult<Vec<String>> {
match self.get_env::<String>("APP_QDRANT_CLUSTER_NODES")? {
Some(s) if !s.is_empty() => Ok(s
.split(',')
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())
.collect()),
_ => Ok(Vec::new()),
}
}
pub fn qdrant_api_key(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_QDRANT_API_KEY")
}
pub fn qdrant_collection(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_QDRANT_COLLECTION")
}
pub fn qdrant_vector_size(&self) -> AppResult<u32> {
self.get_env_or("APP_QDRANT_VECTOR_SIZE", 1536)
}
pub fn qdrant_distance(&self) -> AppResult<String> {
self.get_env_or("APP_QDRANT_DISTANCE", "Cosine".to_string())
}
pub fn qdrant_max_connections(&self) -> AppResult<u32> {
self.get_env_or("APP_QDRANT_MAX_CONNECTIONS", 10)
}
pub fn qdrant_idle_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_QDRANT_IDLE_TIMEOUT", 300)
}
pub fn qdrant_connection_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_QDRANT_CONNECTION_TIMEOUT", 10)
}
pub fn qdrant_max_retries(&self) -> AppResult<u32> {
self.get_env_or("APP_QDRANT_MAX_RETRIES", 3)
}
pub fn qdrant_tls_enabled(&self) -> AppResult<bool> {
self.get_env_or("APP_QDRANT_TLS_ENABLED", true)
}
pub fn qdrant_search_limit(&self) -> AppResult<u32> {
self.get_env_or("APP_QDRANT_SEARCH_LIMIT", 10)
}
pub fn qdrant_score_threshold(&self) -> AppResult<f64> {
self.get_env_or("APP_QDRANT_SCORE_THRESHOLD", 0.7)
}
}
+87
View File
@@ -0,0 +1,87 @@
use crate::config::AppConfig;
use crate::error::{AppError, AppResult};
impl AppConfig {
pub fn redis_url(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_REDIS_URL")
}
pub fn redis_cluster_enabled(&self) -> AppResult<bool> {
self.get_env_or("APP_REDIS_CLUSTER_ENABLED", false)
}
pub fn redis_cluster_nodes(&self) -> AppResult<Vec<String>> {
match self.get_env::<String>("APP_REDIS_CLUSTER_NODES")? {
Some(s) if !s.is_empty() => Ok(s
.split(',')
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())
.collect()),
_ => Ok(Vec::new()),
}
}
pub fn redis_read_from_replicas(&self) -> AppResult<bool> {
self.get_env_or("APP_REDIS_READ_FROM_REPLICAS", false)
}
pub fn redis_username(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_REDIS_USERNAME")
}
pub fn redis_password(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_REDIS_PASSWORD")
}
pub fn redis_database(&self) -> AppResult<u8> {
self.get_env_or("APP_REDIS_DATABASE", 0u8)
}
pub fn redis_max_connections(&self) -> AppResult<u32> {
self.get_env_or("APP_REDIS_MAX_CONNECTIONS", 20)
}
pub fn redis_min_connections(&self) -> AppResult<u32> {
self.get_env_or("APP_REDIS_MIN_CONNECTIONS", 2)
}
pub fn redis_idle_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_REDIS_IDLE_TIMEOUT", 300)
}
pub fn redis_connection_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_REDIS_CONNECTION_TIMEOUT", 5)
}
pub fn redis_max_retries(&self) -> AppResult<u32> {
self.get_env_or("APP_REDIS_MAX_RETRIES", 3)
}
pub fn redis_retry_delay_ms(&self) -> AppResult<u64> {
self.get_env_or("APP_REDIS_RETRY_DELAY_MS", 100)
}
pub fn redis_tls_enabled(&self) -> AppResult<bool> {
self.get_env_or("APP_REDIS_TLS_ENABLED", false)
}
pub fn redis_key_prefix(&self) -> AppResult<String> {
self.get_env_or("APP_REDIS_KEY_PREFIX", "".to_string())
}
pub fn redis_validate(&self) -> AppResult<()> {
if self.redis_cluster_enabled()? {
let nodes = self.redis_cluster_nodes()?;
if nodes.is_empty() {
return Err(AppError::Config(
"Redis cluster enabled but APP_REDIS_CLUSTER_NODES is empty".into(),
));
}
} else if self.redis_url()?.is_none() {
return Err(AppError::Config(
"Redis cluster disabled but APP_REDIS_URL is not set".into(),
));
}
Ok(())
}
}
+30
View File
@@ -0,0 +1,30 @@
use crate::config::AppConfig;
use crate::error::AppResult;
impl AppConfig {
pub fn rpc_self_host(&self) -> AppResult<String> {
self.get_env_or("APP_RPC_SELF_HOST", "0.0.0.0".to_string())
}
pub fn rpc_self_port(&self) -> AppResult<u16> {
self.get_env_or("APP_RPC_SELF_PORT", 50050u16)
}
pub fn rpc_self_listen_addr(&self) -> AppResult<String> {
let host = self.rpc_self_host()?;
let port = self.rpc_self_port()?;
Ok(format!("{host}:{port}"))
}
pub fn rpc_self_reflection(&self) -> AppResult<bool> {
self.get_env_or("APP_RPC_SELF_REFLECTION", false)
}
pub fn rpc_self_service_name(&self) -> AppResult<String> {
self.get_env_or("APP_RPC_SELF_SERVICE_NAME", "appks".to_string())
}
pub fn rpc_default_timeout_secs(&self) -> AppResult<u64> {
self.get_env_or("APP_RPC_DEFAULT_TIMEOUT_SECS", 10)
}
}
+64
View File
@@ -0,0 +1,64 @@
use crate::config::AppConfig;
use crate::error::AppResult;
impl AppConfig {
pub fn s3_endpoint(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_S3_ENDPOINT")
}
pub fn s3_region(&self) -> AppResult<String> {
self.get_env_or("APP_S3_REGION", "us-east-1".to_string())
}
pub fn s3_access_key(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_S3_ACCESS_KEY")
}
pub fn s3_secret_key(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_S3_SECRET_KEY")
}
pub fn s3_bucket(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_S3_BUCKET")
}
pub fn s3_path_style(&self) -> AppResult<bool> {
self.get_env_or("APP_S3_PATH_STYLE", false)
}
pub fn s3_public_url(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_S3_PUBLIC_URL")
}
pub fn s3_max_connections(&self) -> AppResult<u32> {
self.get_env_or("APP_S3_MAX_CONNECTIONS", 50)
}
pub fn s3_idle_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_S3_IDLE_TIMEOUT", 90)
}
pub fn s3_connection_timeout(&self) -> AppResult<u64> {
self.get_env_or("APP_S3_CONNECTION_TIMEOUT", 10)
}
pub fn s3_max_retries(&self) -> AppResult<u32> {
self.get_env_or("APP_S3_MAX_RETRIES", 3)
}
pub fn s3_upload_part_size(&self) -> AppResult<u64> {
self.get_env_or("APP_S3_UPLOAD_PART_SIZE", 8 * 1024 * 1024)
}
pub fn s3_max_upload_size(&self) -> AppResult<u64> {
self.get_env_or("APP_S3_MAX_UPLOAD_SIZE", 100 * 1024 * 1024)
}
pub fn s3_presigned_url_expiry(&self) -> AppResult<u64> {
self.get_env_or("APP_S3_PRESIGNED_URL_EXPIRY", 3600)
}
pub fn s3_force_path_style(&self) -> AppResult<bool> {
self.get_env_or("APP_S3_FORCE_PATH_STYLE", false)
}
}
+105
View File
@@ -0,0 +1,105 @@
use thiserror::Error;
pub type AppResult<T> = Result<T, AppError>;
#[derive(Debug, Error)]
pub enum AppError {
#[error("config error: {0}")]
Config(String),
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("redis error: {0}")]
Redis(#[from] redis::RedisError),
#[error("r2d2 error: {0}")]
R2d2(#[from] r2d2::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("storage error: {0}")]
Storage(#[from] object_store::Error),
#[error("parse error: {0}")]
Parse(String),
#[error("user not found")]
UserNotFound,
#[error("password too weak")]
PasswordTooWeak,
#[error("password hash error: {0}")]
PasswordHashError(String),
#[error("invalid password")]
InvalidPassword,
#[error("account already exists")]
AccountAlreadyExists,
#[error("captcha error")]
CaptchaError,
#[error("rsa key generation failed")]
RsaGenerationError,
#[error("rsa decode error")]
RsaDecodeError,
#[error("invalid two-factor code")]
InvalidTwoFactorCode,
#[error("two-factor authentication required")]
TwoFactorRequired,
#[error("two-factor already enabled")]
TwoFactorAlreadyEnabled,
#[error("two-factor not set up")]
TwoFactorNotSetup,
#[error("two-factor not enabled")]
TwoFactorNotEnabled,
#[error("invalid reset token")]
InvalidResetToken,
#[error("reset token expired")]
ResetTokenExpired,
#[error("email already exists")]
EmailExists,
#[error("invalid email code")]
InvalidEmailCode,
#[error("unauthorized")]
Unauthorized,
#[error("forbidden: {0}")]
Forbidden(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("quota exceeded: {0}")]
QuotaExceeded(String),
#[error("not found: {0}")]
NotFound(String),
#[error("internal server error: {0}")]
InternalServerError(String),
#[error("transaction error")]
TxnError,
}
+160
View File
@@ -0,0 +1,160 @@
use etcd_client::{GetOptions, WatchOptions};
use tokio_stream::StreamExt;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::pb::{EmailClient, RepoClient};
use super::types::ServiceInstance;
use super::{EtcdRegistry, EtcdRegistryInner};
impl EtcdRegistry {
pub async fn start_discovery(&self) -> AppResult<()> {
self.load_initial("git").await?;
self.load_initial("mail").await?;
self.spawn_watch("git");
self.spawn_watch("mail");
Ok(())
}
async fn load_initial(&self, service: &str) -> AppResult<()> {
let prefix = self.service_prefix(service);
let resp = {
let mut client = self.inner.client.lock().await;
client
.get(prefix.as_str(), Some(GetOptions::new().with_prefix()))
.await
.map_err(|e| AppError::Config(format!("etcd get {prefix} failed: {e}")))?
};
for kv in resp.kvs() {
let key = kv.key_str().unwrap_or_default();
let value = kv.value_str().unwrap_or_default();
if let Ok(instance) = serde_json::from_str::<ServiceInstance>(value) {
Self::upsert_instance(&self.inner, service, key, &instance);
}
}
tracing::info!(
service = service,
prefix = prefix.as_str(),
"etcd initial discovery complete"
);
Ok(())
}
fn spawn_watch(&self, service: &str) {
let prefix = self.service_prefix(service);
let inner = self.inner.clone();
let service = service.to_string();
tokio::spawn(async move {
loop {
match Self::watch_loop(&inner, &prefix, &service).await {
Ok(()) => break,
Err(e) => {
tracing::warn!(
service = service.as_str(),
error = %e,
"etcd watch disconnected, retrying in 3s"
);
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
}
}
});
}
async fn watch_loop(inner: &EtcdRegistryInner, prefix: &str, service: &str) -> AppResult<()> {
let (mut watcher, mut stream) = {
let mut client = inner.client.lock().await;
client
.watch(prefix, Some(WatchOptions::new().with_prefix()))
.await
.map_err(|e| AppError::Config(format!("etcd watch {prefix} failed: {e}")))?
};
let _keep = &mut watcher;
while let Some(resp) = stream.next().await {
let resp =
resp.map_err(|e| AppError::Config(format!("etcd watch stream error: {e}")))?;
for event in resp.events() {
let Some(kv) = event.kv() else { continue };
let key = kv.key_str().unwrap_or_default();
match event.event_type() {
etcd_client::EventType::Put => {
let value = kv.value_str().unwrap_or_default();
if let Ok(instance) = serde_json::from_str::<ServiceInstance>(value) {
Self::upsert_instance(inner, service, key, &instance);
tracing::info!(service = service, key = key, "etcd service upserted");
}
}
etcd_client::EventType::Delete => {
Self::remove_instance(inner, service, key);
tracing::info!(service = service, key = key, "etcd service removed");
}
}
}
}
Ok(())
}
pub(crate) fn service_prefix(&self, service: &str) -> String {
format!("{}services/{service}/", self.inner.key_prefix)
}
fn extract_id_from_key(key: &str) -> Option<Uuid> {
key.rsplit('/').next()?.parse().ok()
}
fn upsert_instance(
inner: &EtcdRegistryInner,
service: &str,
key: &str,
instance: &ServiceInstance,
) {
let Some(node_id) = Self::extract_id_from_key(key) else {
tracing::warn!(key = key, "etcd key has no valid UUID suffix");
return;
};
let addr = instance.addr.clone();
match service {
"git" => match RepoClient::lazy_connect(&addr) {
Ok(client) => {
inner.git_nodes.insert(node_id, client);
}
Err(e) => {
tracing::error!(key = key, addr = addr.as_str(), error = %e, "git client connect failed");
}
},
"mail" => match EmailClient::lazy_connect(&addr) {
Ok(client) => {
inner.mail_nodes.insert(node_id, client);
}
Err(e) => {
tracing::error!(key = key, addr = addr.as_str(), error = %e, "mail client connect failed");
}
},
_ => {}
}
}
fn remove_instance(inner: &EtcdRegistryInner, service: &str, key: &str) {
let Some(node_id) = Self::extract_id_from_key(key) else {
return;
};
match service {
"git" => {
inner.git_nodes.remove(&node_id);
}
"mail" => {
inner.mail_nodes.remove(&node_id);
}
_ => {}
}
}
}
+88
View File
@@ -0,0 +1,88 @@
mod discovery;
mod register;
mod types;
pub use types::ServiceInstance;
use std::sync::Arc;
use std::sync::atomic::AtomicI64;
use dashmap::DashMap;
use etcd_client::Client;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::config::AppConfig;
use crate::error::{AppError, AppResult};
use crate::pb::{EmailClient, RepoClient};
#[derive(Clone)]
pub struct EtcdRegistry {
pub(crate) inner: Arc<EtcdRegistryInner>,
}
pub(crate) struct EtcdRegistryInner {
pub client: Mutex<Client>,
pub config: AppConfig,
pub key_prefix: String,
pub git_nodes: DashMap<Uuid, RepoClient>,
pub mail_nodes: DashMap<Uuid, EmailClient>,
pub lease_id: AtomicI64,
}
impl EtcdRegistry {
pub async fn connect(config: &AppConfig) -> AppResult<Self> {
let endpoints = config.etcd_endpoints()?;
let timeout = config.etcd_connect_timeout()?;
let opts = etcd_client::ConnectOptions::new()
.with_connect_timeout(std::time::Duration::from_secs(timeout));
let client = Client::connect(&endpoints, Some(opts))
.await
.map_err(|e| AppError::Config(format!("etcd connect failed: {e}")))?;
if let (Some(user), Some(pass)) = (config.etcd_username()?, config.etcd_password()?) {
let auth_resp = client
.auth_client()
.authenticate(user, pass)
.await
.map_err(|e| AppError::Config(format!("etcd auth failed: {e}")))?;
let token = auth_resp.token().to_string();
tracing::info!(token_len = token.len(), "etcd authenticated");
}
let key_prefix = config.etcd_key_prefix()?;
Ok(Self {
inner: Arc::new(EtcdRegistryInner {
client: Mutex::new(client),
config: config.clone(),
key_prefix,
git_nodes: DashMap::new(),
mail_nodes: DashMap::new(),
lease_id: AtomicI64::new(0),
}),
})
}
pub fn get_git_client(&self, node_id: &Uuid) -> Option<RepoClient> {
self.inner.git_nodes.get(node_id).map(|c| c.clone())
}
pub fn git_node_ids(&self) -> Vec<Uuid> {
self.inner.git_nodes.iter().map(|e| *e.key()).collect()
}
pub fn get_email_client(&self) -> Option<EmailClient> {
self.inner
.mail_nodes
.iter()
.next()
.map(|e| e.value().clone())
}
pub fn has_git_nodes(&self) -> bool {
!self.inner.git_nodes.is_empty()
}
}
+113
View File
@@ -0,0 +1,113 @@
use std::collections::HashMap;
use std::sync::atomic::Ordering;
use etcd_client::PutOptions;
use tokio_stream::StreamExt;
use crate::error::{AppError, AppResult};
use super::EtcdRegistry;
use super::types::ServiceInstance;
impl EtcdRegistry {
pub async fn register_self(&self, service_name: &str) -> AppResult<()> {
let ttl = self.inner.config.etcd_lease_ttl()?;
let listen_addr = self.inner.config.rpc_self_listen_addr()?;
let instance_id = uuid::Uuid::now_v7().to_string();
let key = format!(
"{}services/{service_name}/{instance_id}",
self.inner.key_prefix
);
let instance = ServiceInstance {
addr: listen_addr.clone(),
metadata: HashMap::new(),
};
let value = serde_json::to_string(&instance)?;
let lease_resp = {
let mut client = self.inner.client.lock().await;
client
.lease_grant(ttl as i64, None)
.await
.map_err(|e| AppError::Config(format!("etcd lease_grant failed: {e}")))?
};
let lease_id = lease_resp.id();
self.inner.lease_id.store(lease_id, Ordering::SeqCst);
{
let mut client = self.inner.client.lock().await;
let opts = PutOptions::new().with_lease(lease_id);
client
.put(key.clone(), value, Some(opts))
.await
.map_err(|e| AppError::Config(format!("etcd put failed: {e}")))?;
}
tracing::info!(
service = service_name,
addr = listen_addr.as_str(),
lease_id = lease_id,
"registered self in etcd"
);
self.spawn_keep_alive(lease_id, key);
Ok(())
}
fn spawn_keep_alive(&self, lease_id: i64, key: String) {
let inner = self.inner.clone();
let interval = self.inner.config.etcd_keep_alive_interval().unwrap_or(10);
tokio::spawn(async move {
loop {
let result = {
let mut client = inner.client.lock().await;
client.lease_keep_alive(lease_id).await
};
match result {
Ok((_keeper, mut stream)) => {
while let Some(resp) = stream.next().await {
if let Err(e) = resp {
tracing::warn!(lease_id = lease_id, error = %e, "keep-alive stream error");
break;
}
}
}
Err(e) => {
tracing::warn!(lease_id = lease_id, error = %e, "keep-alive failed");
}
}
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
let re_grant = {
let mut client = inner.client.lock().await;
client
.lease_grant(inner.config.etcd_lease_ttl().unwrap_or(15) as i64, None)
.await
};
if let Ok(current) = re_grant {
let new_lease = current.id();
inner.lease_id.store(new_lease, Ordering::SeqCst);
let instance = ServiceInstance {
addr: inner.config.rpc_self_listen_addr().unwrap_or_default(),
metadata: HashMap::new(),
};
if let Ok(value) = serde_json::to_string(&instance) {
let mut client = inner.client.lock().await;
let opts = PutOptions::new().with_lease(new_lease);
let _ = client.put(key.clone(), value, Some(opts)).await;
}
tracing::info!(old = lease_id, new = new_lease, "etcd lease renewed");
}
}
});
}
}
+10
View File
@@ -0,0 +1,10 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceInstance {
pub addr: String,
#[serde(default)]
pub metadata: HashMap<String, String>,
}
+200
View File
@@ -0,0 +1,200 @@
use std::sync::Arc;
use futures_util::StreamExt;
use uuid::Uuid;
use crate::queue::NatsQueue;
use super::{
ArticleEvent, CategoryEvent, DraftEvent, FollowEvent, MemberEvent, MessageEvent, PollEvent,
PresenceEvent, ReactionEvent, ThreadEvent, TypingEvent, WsOutbound, WsSessionManager,
WsSinkManager,
};
#[derive(Clone)]
pub struct NatsWsBridge {
queue: Arc<NatsQueue>,
sessions: Arc<WsSessionManager>,
sinks: Arc<WsSinkManager>,
}
impl NatsWsBridge {
pub fn new(
queue: Arc<NatsQueue>,
sessions: Arc<WsSessionManager>,
sinks: Arc<WsSinkManager>,
) -> Self {
Self {
queue,
sessions,
sinks,
}
}
pub async fn run_ephemeral(self, subject: &str) {
let Ok(mut sub) = self.queue.subscribe_ephemeral(subject.to_string()).await else {
tracing::warn!(subject, "nats ws bridge subscribe failed");
return;
};
while let Some(msg) = sub.next().await {
self.dispatch(msg.subject.as_str(), msg.payload.as_ref(), request_id(&msg))
.await;
}
}
async fn dispatch(&self, subject: &str, payload: &[u8], request_id: Uuid) {
if subject.starts_with("im.message.") {
self.channel_event(payload, |data| WsOutbound::Message { request_id, data });
} else if subject.starts_with("im.thread.") {
self.channel_event(payload, |data| WsOutbound::Thread { request_id, data });
} else if subject.starts_with("im.member.") {
self.channel_event(payload, |data| WsOutbound::Member { request_id, data });
} else if subject.starts_with("im.reaction.") {
self.channel_event(payload, |data| WsOutbound::Reaction { request_id, data });
} else if subject.starts_with("im.poll.") {
self.channel_event(payload, |data| WsOutbound::Poll { request_id, data });
} else if subject.starts_with("im.article.") {
self.channel_event(payload, |data| WsOutbound::Article { request_id, data });
} else if subject.starts_with("im.typing.") {
self.channel_event(payload, |data| WsOutbound::Typing { request_id, data });
} else if subject.starts_with("im.presence.") {
self.presence_event(payload, request_id);
} else if subject.starts_with("im.channel.") {
self.channel_meta_event(subject, payload, request_id);
} else if subject.starts_with("im.category.") {
self.category_event(payload, request_id);
} else if subject.starts_with("im.draft.") {
self.draft_event(payload, request_id);
} else if subject.starts_with("im.follow.") {
self.channel_event(payload, |data| WsOutbound::Follow { request_id, data });
}
}
fn channel_event<T, F>(&self, payload: &[u8], build: F)
where
T: serde::de::DeserializeOwned + ChannelScoped,
F: Fn(T) -> WsOutbound,
{
let Ok(data) = serde_json::from_slice::<T>(payload) else {
tracing::warn!("nats ws bridge decode channel event failed");
return;
};
let channel_id = data.channel_id();
let subscribers = self.sessions.subscribers(channel_id);
let delivered = self.sinks.send_many(subscribers, build(data));
tracing::debug!(%channel_id, delivered, "nats event forwarded to ws subscribers");
}
fn presence_event(&self, payload: &[u8], request_id: Uuid) {
let Ok(data) = serde_json::from_slice::<PresenceEvent>(payload) else {
tracing::warn!("nats ws bridge decode presence event failed");
return;
};
let ids = self.sessions.user_connections(data.user_id);
let delivered = self
.sinks
.send_many(ids, WsOutbound::Presence { request_id, data });
tracing::debug!(delivered, "nats presence forwarded to ws subscribers");
}
fn category_event(&self, payload: &[u8], request_id: Uuid) {
let Ok(data) = serde_json::from_slice::<CategoryEvent>(payload) else {
tracing::warn!("nats ws bridge decode category event failed");
return;
};
let targets = self.sessions.workspace_connections(&data.workspace_name);
let delivered = self
.sinks
.send_many(targets, WsOutbound::Category { request_id, data });
tracing::debug!(delivered, "nats category event forwarded to ws subscribers");
}
fn draft_event(&self, payload: &[u8], request_id: Uuid) {
let Ok(data) = serde_json::from_slice::<DraftEvent>(payload) else {
tracing::warn!("nats ws bridge decode draft event failed");
return;
};
let targets = self.sessions.user_connections(data.user_id);
let delivered = self
.sinks
.send_many(targets, WsOutbound::Draft { request_id, data });
tracing::debug!(delivered, "nats draft event forwarded to ws subscribers");
}
fn channel_meta_event(&self, subject: &str, payload: &[u8], request_id: Uuid) {
let Ok(data) = serde_json::from_slice::<super::ChannelEvent>(payload) else {
tracing::warn!("nats ws bridge decode channel event failed");
return;
};
let mut targets = data
.workspace_name
.as_deref()
.map(|workspace| self.sessions.workspace_connections(workspace))
.unwrap_or_else(|| self.sessions.subscribers(data.channel_id));
if targets.is_empty()
&& let Some(id) = subject
.rsplit('.')
.next()
.and_then(|v| v.parse::<Uuid>().ok())
{
targets = self.sessions.subscribers(id);
}
let delivered = self
.sinks
.send_many(targets, WsOutbound::Channel { request_id, data });
tracing::debug!(delivered, "nats channel event forwarded to ws subscribers");
}
}
pub trait ChannelScoped {
fn channel_id(&self) -> Uuid;
}
impl ChannelScoped for MessageEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for ThreadEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for MemberEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for ReactionEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for PollEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for ArticleEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for TypingEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for FollowEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
fn request_id(msg: &async_nats::Message) -> Uuid {
msg.headers
.as_ref()
.and_then(|h| h.get("X-Request-Id"))
.and_then(|v| v.as_str().parse().ok())
.unwrap_or_else(Uuid::nil)
}
+58
View File
@@ -0,0 +1,58 @@
use uuid::Uuid;
use crate::cache::redis::AppRedis;
use crate::error::AppResult;
use ::redis::Cmd;
use super::redis_keys::*;
pub struct DedupManager {
redis: AppRedis,
window_secs: u64,
}
impl DedupManager {
pub fn new(redis: AppRedis) -> Self {
Self {
redis,
window_secs: WS_DEDUP_WINDOW_SECS,
}
}
pub fn check_and_mark(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<bool> {
let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}");
let mut conn = self.redis.get_connection()?;
let result: Option<String> = Cmd::new()
.arg("SET")
.arg(&key)
.arg("1")
.arg("NX")
.arg("EX")
.arg(self.window_secs)
.query(&mut *conn.inner_mut())
.map_err(crate::error::AppError::Redis)?;
Ok(result.is_some())
}
pub fn is_duplicate(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<bool> {
let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}");
let mut conn = self.redis.get_connection()?;
let exists: bool = Cmd::new()
.arg("EXISTS")
.arg(&key)
.query(&mut *conn.inner_mut())
.map_err(crate::error::AppError::Redis)?;
Ok(exists)
}
pub fn clear(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<()> {
let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}");
let mut conn = self.redis.get_connection()?;
Cmd::new()
.arg("DEL")
.arg(&key)
.query::<()>(&mut *conn.inner_mut())
.map_err(crate::error::AppError::Redis)?;
Ok(())
}
}
+39
View File
@@ -0,0 +1,39 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransportEnvelope<T> {
#[serde(default = "Uuid::now_v7")]
pub message_id: Uuid,
pub request_id: Uuid,
pub user_id: Uuid,
pub payload: T,
#[serde(default = "default_timestamp")]
pub created_at: chrono::DateTime<chrono::Utc>,
#[serde(default)]
pub attempt: u8,
}
fn default_timestamp() -> chrono::DateTime<chrono::Utc> {
chrono::Utc::now()
}
impl<T> TransportEnvelope<T> {
pub fn new(request_id: Uuid, user_id: Uuid, payload: T) -> Self {
Self {
message_id: Uuid::now_v7(),
request_id,
user_id,
payload,
created_at: chrono::Utc::now(),
attempt: 1,
}
}
pub fn retry(self) -> Self {
Self {
attempt: self.attempt + 1,
..self
}
}
}
+447
View File
@@ -0,0 +1,447 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::immediate::dedup::DedupManager;
use crate::immediate::limiter::HandlerLimiter;
use crate::immediate::nats::ImNats;
use crate::immediate::outbound::*;
use crate::immediate::rate_limit::{LocalRateLimiter, RateLimiter};
use crate::immediate::reconnect::ReconnectManager;
use crate::immediate::session::{WsSession, WsSessionManager};
use crate::service::ImService;
use crate::service::im::messages::EditMessageParams;
use crate::service::im::messages::SendMessageParams;
use crate::service::im::presence::UpdatePresenceParams;
use crate::service::im::session::ImSession;
use super::inbound::WsInbound;
use super::redis_keys::*;
use super::sink::WsSinkManager;
#[allow(dead_code)]
#[derive(Clone)]
pub struct WsHandler {
nats: Arc<ImNats>,
manager: Arc<WsSessionManager>,
sinks: Arc<WsSinkManager>,
service: ImService,
dedup: Arc<DedupManager>,
rate_limiter: Arc<RateLimiter>,
local_limiter: Arc<LocalRateLimiter>,
handler_limiter: Arc<HandlerLimiter>,
reconnect: Arc<ReconnectManager>,
session: Option<WsSession>,
}
#[allow(dead_code)]
impl WsHandler {
pub fn new(
manager: Arc<WsSessionManager>,
sinks: Arc<WsSinkManager>,
service: ImService,
nats: Arc<ImNats>,
dedup: Arc<DedupManager>,
rate_limiter: Arc<RateLimiter>,
reconnect: Arc<ReconnectManager>,
) -> Self {
Self {
nats,
manager,
sinks,
service,
dedup,
rate_limiter,
local_limiter: Arc::new(LocalRateLimiter::new(WS_MAX_MESSAGES_PER_SEC)),
handler_limiter: Arc::new(HandlerLimiter::new(1024)),
reconnect,
session: None,
}
}
pub fn session(&self) -> Option<&WsSession> {
self.session.as_ref()
}
pub fn is_authenticated(&self) -> bool {
self.session.is_some()
}
pub fn handle_disconnect(&self) {
if let Some(s) = &self.session
&& let Err(e) = self.manager.unregister_connection(s)
{
tracing::warn!(conn = %s.connection_id, error = %e, "unregister failed");
}
}
pub async fn handle(&mut self, msg: WsInbound) -> Vec<WsOutbound> {
match msg {
WsInbound::Auth { request_id, token } => self.handle_auth(request_id, token).await,
m => {
let Some(s) = &self.session else {
return vec![WsOutbound::Error {
request_id: request_id_of(&m),
code: "not_authenticated".into(),
message: "authenticate first".into(),
}];
};
if !self.manager.is_deliverable(s.connection_id) {
return vec![WsOutbound::Error {
request_id: request_id_of(&m),
code: "session_not_active".into(),
message: "session is not active".into(),
}];
}
let Ok(_permit) = self.handler_limiter.try_acquire() else {
return vec![WsOutbound::Error {
request_id: request_id_of(&m),
code: "overloaded".into(),
message: "too many inflight messages".into(),
}];
};
if !self.local_limiter.check() {
return vec![WsOutbound::Error {
request_id: request_id_of(&m),
code: "rate_limit_exceeded".into(),
message: "too many messages".into(),
}];
}
match self.rate_limiter.check(s.connection_id) {
Ok(true) => {}
Ok(false) => {
return vec![WsOutbound::Error {
request_id: request_id_of(&m),
code: "rate_limit_exceeded".into(),
message: "too many messages".into(),
}];
}
Err(e) => tracing::warn!(error = %e, "rate limit check failed"),
}
self.dispatch(s, m).await
}
}
}
async fn dispatch(&self, session: &WsSession, msg: WsInbound) -> Vec<WsOutbound> {
match msg {
WsInbound::Heartbeat { request_id } => {
if let Err(e) = self.manager.heartbeat(session) {
tracing::warn!(user = %session.user_id, error = %e, "heartbeat failed");
}
vec![WsOutbound::HeartbeatAck {
request_id,
timestamp_ms: chrono::Utc::now().timestamp_millis(),
}]
}
WsInbound::JoinChannel {
request_id,
channel_id,
} => match self.service.resolve_channel(channel_id).await {
Ok(channel) => match self
.service
.ensure_channel_readable(session.user_id, &channel)
.await
{
Ok(()) => {
self.manager
.subscribe_channel(session.connection_id, channel_id);
vec![]
}
Err(e) => vec![WsOutbound::Error {
request_id,
code: "join_channel_failed".into(),
message: e.to_string(),
}],
},
Err(e) => vec![WsOutbound::Error {
request_id,
code: "join_channel_failed".into(),
message: e.to_string(),
}],
},
WsInbound::LeaveChannel {
request_id: _,
channel_id,
} => {
self.manager
.unsubscribe_channel(session.connection_id, channel_id);
vec![]
}
WsInbound::TypingStart {
request_id,
channel_id,
thread_id,
} => {
let _ = self
.manager
.set_typing(channel_id, thread_id, session.user_id);
self.nats
.emit(
&ImNats::typing_subject(channel_id),
request_id,
&TypingEvent {
channel_id,
thread_id,
user_id: session.user_id,
},
)
.await;
vec![]
}
WsInbound::TypingStop {
request_id: _,
channel_id,
thread_id,
} => {
let _ = self
.manager
.clear_typing(channel_id, thread_id, session.user_id);
vec![]
}
WsInbound::MessageSend {
request_id,
channel_id,
body,
thread_id,
reply_to,
message_type,
} => {
if body.len() > WS_MAX_MESSAGE_BYTES {
return vec![WsOutbound::Error {
request_id,
code: "message_too_large".into(),
message: "message body too large".into(),
}];
}
match self.dedup.check_and_mark(request_id, channel_id) {
Ok(true) => {}
Ok(false) => {
return vec![WsOutbound::Error {
request_id,
code: "duplicate".into(),
message: "duplicate message".into(),
}];
}
Err(e) => tracing::warn!(error = %e, "dedup check failed"),
}
let ctx = ImSession::new(session.user_id);
let params = SendMessageParams {
body,
message_type,
thread_id,
reply_to_message_id: reply_to,
pinned: None,
attachments: None,
embeds: None,
};
match self
.service
.message_send(
&ctx,
&session.workspace_name,
channel_id,
params,
request_id,
)
.await
{
Ok(msg) => vec![WsOutbound::SeqAck {
request_id,
channel_id,
seq: msg.seq,
}],
Err(e) => {
if let Err(clear_err) = self.dedup.clear(request_id, channel_id) {
tracing::warn!(error = %clear_err, "dedup clear failed after message send error");
}
vec![WsOutbound::Error {
request_id,
code: "message_send_failed".into(),
message: e.to_string(),
}]
}
}
}
WsInbound::MessageEdit {
request_id,
channel_id,
message_id,
body,
} => {
if body.len() > WS_MAX_MESSAGE_BYTES {
return vec![WsOutbound::Error {
request_id,
code: "message_too_large".into(),
message: "message body too large".into(),
}];
}
let ctx = ImSession::new(session.user_id);
let params = EditMessageParams { body };
match self
.service
.message_edit(
&ctx,
&session.workspace_name,
channel_id,
message_id,
params,
request_id,
)
.await
{
Ok(_) => vec![],
Err(e) => vec![WsOutbound::Error {
request_id,
code: "message_edit_failed".into(),
message: e.to_string(),
}],
}
}
WsInbound::MessageDelete {
request_id,
channel_id,
message_id,
} => {
let ctx = ImSession::new(session.user_id);
match self
.service
.message_delete(
&ctx,
&session.workspace_name,
channel_id,
message_id,
request_id,
)
.await
{
Ok(()) => vec![],
Err(e) => vec![WsOutbound::Error {
request_id,
code: "message_delete_failed".into(),
message: e.to_string(),
}],
}
}
WsInbound::PresenceUpdate {
request_id,
status,
custom_status_text,
custom_status_emoji,
} => {
let ctx = ImSession::new(session.user_id);
let params = UpdatePresenceParams {
status,
custom_status_text: custom_status_text.clone(),
custom_status_emoji: custom_status_emoji.clone(),
};
match self
.service
.presence_update(&ctx, &session.workspace_name, params)
.await
{
Ok(p) => {
self.nats
.emit(
&ImNats::presence_subject(session.user_id),
request_id,
&PresenceEvent {
user_id: session.user_id,
status: p.status.to_string(),
custom_status_text,
custom_status_emoji,
},
)
.await;
vec![]
}
Err(e) => vec![WsOutbound::Error {
request_id,
code: "presence_update_failed".into(),
message: e.to_string(),
}],
}
}
WsInbound::ReadReceipt {
request_id,
channel_id,
last_read_message_id,
last_seq,
} => {
if let Some(seq) = last_seq
&& let Err(e) =
self.reconnect
.save_read_position(session.user_id, channel_id, seq)
{
tracing::warn!(error = %e, "save read position failed");
}
vec![WsOutbound::ReadReceiptAck {
request_id,
channel_id,
last_read_message_id,
last_seq,
}]
}
WsInbound::Auth { .. } => unreachable!(),
}
}
fn close_replaced_connection(&self, old_id: Uuid, new_id: Uuid) {
let _ = self.sinks.send(
old_id,
WsOutbound::Error {
request_id: Uuid::nil(),
code: "session_replaced".into(),
message: format!("session replaced by {new_id}"),
},
);
self.sinks.detach(old_id);
if let Some(old) = self.manager.get_session(old_id)
&& let Err(e) = self.manager.unregister_connection(&old)
{
tracing::warn!(conn = %old_id, error = %e, "unregister replaced connection failed");
}
}
async fn handle_auth(&mut self, request_id: Uuid, token: String) -> Vec<WsOutbound> {
match self.manager.redeem_token(&token) {
Ok(session) => {
match self.manager.register_connection_with_replacement(&session) {
Ok(Some(old_id)) => {
self.close_replaced_connection(old_id, session.connection_id)
}
Ok(None) => {}
Err(e) => tracing::warn!(error = %e, "register connection failed"),
}
let cid = session.connection_id;
let interval = self.manager.heartbeat_interval_secs();
self.session = Some(session);
vec![WsOutbound::AuthOk {
request_id,
connection_id: cid,
heartbeat_interval_secs: interval,
}]
}
Err(e) => vec![WsOutbound::AuthError {
request_id,
message: e.to_string(),
}],
}
}
}
#[allow(dead_code)]
fn request_id_of(msg: &WsInbound) -> Uuid {
match msg {
WsInbound::Auth { request_id, .. } => *request_id,
WsInbound::Heartbeat { request_id } => *request_id,
WsInbound::JoinChannel { request_id, .. } => *request_id,
WsInbound::LeaveChannel { request_id, .. } => *request_id,
WsInbound::TypingStart { request_id, .. } => *request_id,
WsInbound::TypingStop { request_id, .. } => *request_id,
WsInbound::MessageSend { request_id, .. } => *request_id,
WsInbound::MessageEdit { request_id, .. } => *request_id,
WsInbound::MessageDelete { request_id, .. } => *request_id,
WsInbound::PresenceUpdate { request_id, .. } => *request_id,
WsInbound::ReadReceipt { request_id, .. } => *request_id,
}
}
+68
View File
@@ -0,0 +1,68 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum WsInbound {
Auth {
request_id: Uuid,
token: String,
},
Heartbeat {
request_id: Uuid,
},
JoinChannel {
request_id: Uuid,
channel_id: Uuid,
},
LeaveChannel {
request_id: Uuid,
channel_id: Uuid,
},
TypingStart {
request_id: Uuid,
channel_id: Uuid,
thread_id: Option<Uuid>,
},
TypingStop {
request_id: Uuid,
channel_id: Uuid,
thread_id: Option<Uuid>,
},
MessageSend {
request_id: Uuid,
channel_id: Uuid,
body: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
reply_to: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
message_type: Option<String>,
},
MessageEdit {
request_id: Uuid,
channel_id: Uuid,
message_id: Uuid,
body: String,
},
MessageDelete {
request_id: Uuid,
channel_id: Uuid,
message_id: Uuid,
},
PresenceUpdate {
request_id: Uuid,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
custom_status_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
custom_status_emoji: Option<String>,
},
ReadReceipt {
request_id: Uuid,
channel_id: Uuid,
last_read_message_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
last_seq: Option<i64>,
},
}
+46
View File
@@ -0,0 +1,46 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HandlerLimitError;
#[derive(Clone)]
pub struct HandlerLimiter {
sem: Arc<Semaphore>,
max_inflight: usize,
rejected: Arc<AtomicU64>,
}
impl HandlerLimiter {
pub fn new(max_inflight: usize) -> Self {
Self {
sem: Arc::new(Semaphore::new(max_inflight)),
max_inflight,
rejected: Arc::new(AtomicU64::new(0)),
}
}
pub fn try_acquire(&self) -> Result<OwnedSemaphorePermit, HandlerLimitError> {
match self.sem.clone().try_acquire_owned() {
Ok(permit) => Ok(permit),
Err(_) => {
self.rejected.fetch_add(1, Ordering::Relaxed);
Err(HandlerLimitError)
}
}
}
pub fn inflight(&self) -> usize {
self.max_inflight - self.sem.available_permits()
}
pub fn available(&self) -> usize {
self.sem.available_permits()
}
pub fn rejected_total(&self) -> u64 {
self.rejected.load(Ordering::Relaxed)
}
}
+36
View File
@@ -0,0 +1,36 @@
mod bridge;
mod dedup;
mod envelope;
mod handler;
mod inbound;
mod limiter;
mod nats;
mod outbound;
mod rate_limit;
mod reconnect;
mod redis_keys;
mod runtime;
mod seq;
mod session;
mod session_redis;
mod sink;
mod typing;
pub use bridge::NatsWsBridge;
pub use dedup::DedupManager;
pub use envelope::TransportEnvelope;
pub use inbound::WsInbound;
pub use limiter::HandlerLimiter;
pub use nats::ImNats;
pub use outbound::{
ArticleAction, ArticleEvent, CategoryAction, CategoryEvent, ChannelAction, ChannelEvent,
DraftAction, DraftEvent, FollowAction, FollowEvent, MemberAction, MemberEvent, MessageAction,
MessageEvent, PollAction, PollEvent, PresenceEvent, ReactionAction, ReactionEvent,
ThreadAction, ThreadEvent, TypingEvent, WsOutbound,
};
pub use rate_limit::{LocalRateLimiter, RateLimiter};
pub use reconnect::ReconnectManager;
pub use runtime::WsRuntime;
pub use seq::SeqAllocator;
pub use session::{WsSession, WsSessionManager, WsSessionState};
pub use sink::{WsReceiver, WsSender, WsSinkManager};
+81
View File
@@ -0,0 +1,81 @@
use std::sync::Arc;
use serde::Serialize;
use uuid::Uuid;
use crate::queue::NatsQueue;
#[derive(Clone)]
pub struct ImNats {
inner: Arc<NatsQueue>,
}
impl ImNats {
pub fn new(nats: Arc<NatsQueue>) -> Self {
Self { inner: nats }
}
pub async fn emit<T: Serialize>(&self, subject: &str, request_id: Uuid, event: &T) {
if let Err(e) = self
.inner
.publish_with_headers(
subject,
&serde_json::to_vec(event).unwrap_or_default(),
vec![("X-Request-Id".into(), request_id.to_string())],
)
.await
{
tracing::warn!(subject, error = %e, "nats emit failed");
}
}
#[inline]
pub fn channel_subject(channel_id: Uuid) -> String {
format!("im.channel.{channel_id}")
}
#[inline]
pub fn message_subject(channel_id: Uuid) -> String {
format!("im.message.{channel_id}")
}
#[inline]
pub fn thread_subject(channel_id: Uuid, thread_id: Uuid) -> String {
format!("im.thread.{channel_id}.{thread_id}")
}
#[inline]
pub fn member_subject(channel_id: Uuid) -> String {
format!("im.member.{channel_id}")
}
#[inline]
pub fn reaction_subject(channel_id: Uuid) -> String {
format!("im.reaction.{channel_id}")
}
#[inline]
pub fn typing_subject(channel_id: Uuid) -> String {
format!("im.typing.{channel_id}")
}
#[inline]
pub fn presence_subject(user_id: Uuid) -> String {
format!("im.presence.{user_id}")
}
#[inline]
pub fn poll_subject(channel_id: Uuid) -> String {
format!("im.poll.{channel_id}")
}
#[inline]
pub fn article_subject(channel_id: Uuid) -> String {
format!("im.article.{channel_id}")
}
#[inline]
pub fn workspace_channels_subject(workspace_name: &str) -> String {
format!("im.ws_channels.{workspace_name}")
}
}
+256
View File
@@ -0,0 +1,256 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum WsOutbound {
AuthOk {
request_id: Uuid,
connection_id: Uuid,
heartbeat_interval_secs: u64,
},
AuthError {
request_id: Uuid,
message: String,
},
HeartbeatAck {
request_id: Uuid,
timestamp_ms: i64,
},
Error {
request_id: Uuid,
code: String,
message: String,
},
Typing {
request_id: Uuid,
data: TypingEvent,
},
Presence {
request_id: Uuid,
data: PresenceEvent,
},
Message {
request_id: Uuid,
data: MessageEvent,
},
Channel {
request_id: Uuid,
data: ChannelEvent,
},
Thread {
request_id: Uuid,
data: ThreadEvent,
},
Member {
request_id: Uuid,
data: MemberEvent,
},
Reaction {
request_id: Uuid,
data: ReactionEvent,
},
Poll {
request_id: Uuid,
data: PollEvent,
},
Article {
request_id: Uuid,
data: ArticleEvent,
},
Category {
request_id: Uuid,
data: CategoryEvent,
},
Draft {
request_id: Uuid,
data: DraftEvent,
},
Follow {
request_id: Uuid,
data: FollowEvent,
},
ReadReceiptAck {
request_id: Uuid,
channel_id: Uuid,
last_read_message_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
last_seq: Option<i64>,
},
SeqAck {
request_id: Uuid,
channel_id: Uuid,
seq: i64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypingEvent {
pub channel_id: Uuid,
pub thread_id: Option<Uuid>,
pub user_id: Uuid,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresenceEvent {
pub user_id: Uuid,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_status_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_status_emoji: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageEvent {
pub channel_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<Uuid>,
pub message_id: Uuid,
pub author_id: Uuid,
pub action: MessageAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seq: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MessageAction {
Created,
Edited,
Deleted,
Pinned,
Unpinned,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelEvent {
pub channel_id: Uuid,
pub action: ChannelAction,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ChannelAction {
Created,
Updated,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadEvent {
pub channel_id: Uuid,
pub thread_id: Uuid,
pub action: ThreadAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ThreadAction {
Created,
Updated,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemberEvent {
pub channel_id: Uuid,
pub user_id: Uuid,
pub action: MemberAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MemberAction {
Joined,
Left,
Kicked,
Updated,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReactionEvent {
pub channel_id: Uuid,
pub message_id: Uuid,
pub user_id: Uuid,
pub action: ReactionAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ReactionAction {
Added,
Removed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PollEvent {
pub channel_id: Uuid,
pub poll_id: Uuid,
pub action: PollAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PollAction {
Created,
Voted,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArticleEvent {
pub channel_id: Uuid,
pub article_id: Uuid,
pub action: ArticleAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ArticleAction {
Created,
Updated,
Published,
Unpublished,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CategoryEvent {
pub workspace_name: String,
pub category_id: Uuid,
pub action: CategoryAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CategoryAction {
Created,
Updated,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DraftEvent {
pub channel_id: Uuid,
pub user_id: Uuid,
pub thread_id: Option<Uuid>,
pub action: DraftAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DraftAction {
Saved,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FollowEvent {
pub channel_id: Uuid,
pub follow_id: Uuid,
pub action: FollowAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FollowAction {
Created,
Deleted,
Retried,
}
+102
View File
@@ -0,0 +1,102 @@
use std::time::Instant;
use uuid::Uuid;
use crate::cache::redis::AppRedis;
use crate::error::AppResult;
use ::redis::Cmd;
use super::redis_keys::*;
pub struct RateLimiter {
redis: AppRedis,
max_per_sec: u32,
}
impl RateLimiter {
pub fn new(redis: AppRedis) -> Self {
Self {
redis,
max_per_sec: WS_MAX_MESSAGES_PER_SEC,
}
}
pub fn with_limit(redis: AppRedis, max_per_sec: u32) -> Self {
Self { redis, max_per_sec }
}
pub fn check(&self, connection_id: Uuid) -> AppResult<bool> {
let key = format!("{WS_RATE_PREFIX}{connection_id}");
let mut conn = self.redis.get_connection()?;
let count: i64 = Cmd::new()
.arg("INCR")
.arg(&key)
.query(&mut *conn.inner_mut())
.map_err(crate::error::AppError::Redis)?;
if count == 1 {
let _ = Cmd::new()
.arg("EXPIRE")
.arg(&key)
.arg(1_u64)
.query::<()>(&mut *conn.inner_mut());
}
Ok(count <= self.max_per_sec as i64)
}
pub fn check_sliding(&self, connection_id: Uuid) -> AppResult<bool> {
let key = format!("{WS_RATE_PREFIX}{connection_id}");
let mut conn = self.redis.get_connection()?;
let count: i64 = Cmd::new()
.arg("INCR")
.arg(&key)
.query(&mut *conn.inner_mut())
.map_err(crate::error::AppError::Redis)?;
if count == 1 {
let _ = Cmd::new()
.arg("EXPIRE")
.arg(&key)
.arg(2_u64)
.query::<()>(&mut *conn.inner_mut());
}
Ok(count <= self.max_per_sec as i64)
}
pub fn remaining(&self, connection_id: Uuid) -> AppResult<u32> {
let key = format!("{WS_RATE_PREFIX}{connection_id}");
let mut conn = self.redis.get_connection()?;
let count: Option<i64> = Cmd::new()
.arg("GET")
.arg(&key)
.query(&mut *conn.inner_mut())
.map_err(crate::error::AppError::Redis)?;
Ok(self.max_per_sec.saturating_sub(count.unwrap_or(0) as u32))
}
}
pub struct LocalRateLimiter {
count: std::sync::atomic::AtomicU32,
start: std::sync::Mutex<Instant>,
max_per_sec: u32,
}
impl LocalRateLimiter {
pub fn new(max_per_sec: u32) -> Self {
Self {
count: std::sync::atomic::AtomicU32::new(0),
start: std::sync::Mutex::new(Instant::now()),
max_per_sec,
}
}
pub fn check(&self) -> bool {
let mut start = self.start.lock().unwrap();
if start.elapsed().as_secs() >= 1 {
self.count.store(0, std::sync::atomic::Ordering::Relaxed);
*start = Instant::now();
}
drop(start);
self.count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
< self.max_per_sec
}
}
+101
View File
@@ -0,0 +1,101 @@
use std::collections::HashMap;
use uuid::Uuid;
use crate::cache::redis::AppRedis;
use crate::error::{AppError, AppResult};
use ::redis::Cmd;
use super::redis_keys::*;
pub struct ReconnectManager {
redis: AppRedis,
}
impl ReconnectManager {
pub fn new(redis: AppRedis) -> Self {
Self { redis }
}
pub fn save_read_position(&self, user_id: Uuid, channel_id: Uuid, seq: i64) -> AppResult<()> {
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
let mut conn = self.redis.get_connection()?;
Cmd::new()
.arg("SETEX")
.arg(&key)
.arg(WS_RECONNECT_STATE_TTL_SECS)
.arg(seq.to_string())
.query::<()>(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
Ok(())
}
pub fn save_read_positions(
&self,
user_id: Uuid,
positions: &HashMap<Uuid, i64>,
) -> AppResult<()> {
let mut conn = self.redis.get_connection()?;
for (channel_id, seq) in positions {
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
Cmd::new()
.arg("SETEX")
.arg(&key)
.arg(WS_RECONNECT_STATE_TTL_SECS)
.arg(seq.to_string())
.query::<()>(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
}
Ok(())
}
pub fn get_last_seq(&self, user_id: Uuid, channel_id: Uuid) -> AppResult<Option<i64>> {
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
let mut conn = self.redis.get_connection()?;
let val: Option<String> = Cmd::new()
.arg("GET")
.arg(&key)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
Ok(val.and_then(|v| v.parse().ok()))
}
pub fn get_all_positions(&self, user_id: Uuid) -> AppResult<HashMap<Uuid, i64>> {
let pattern = format!("{WS_RECONNECT_PREFIX}{user_id}:*");
let mut conn = self.redis.get_connection()?;
let keys: Vec<String> = Cmd::new()
.arg("KEYS")
.arg(&pattern)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
let mut result = HashMap::new();
let prefix_len = format!("{WS_RECONNECT_PREFIX}{user_id}:").len();
for key in &keys {
if let Some(channel_str) = key.get(prefix_len..)
&& let Ok(channel_id) = channel_str.parse::<Uuid>()
{
let val: Option<String> = Cmd::new()
.arg("GET")
.arg(key)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
if let Some(v) = val
&& let Ok(seq) = v.parse::<i64>()
{
result.insert(channel_id, seq);
}
}
}
Ok(result)
}
pub fn cleanup_channel(&self, user_id: Uuid, channel_id: Uuid) -> AppResult<()> {
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
let mut conn = self.redis.get_connection()?;
let _ = Cmd::new()
.arg("DEL")
.arg(&key)
.query::<()>(&mut *conn.inner_mut());
Ok(())
}
}
+19
View File
@@ -0,0 +1,19 @@
#![allow(dead_code)]
pub const WS_TOKEN_PREFIX: &str = "im:ws:token:";
pub const WS_ONLINE_PREFIX: &str = "im:ws:online:";
pub const WS_CONNS_PREFIX: &str = "im:ws:conns:";
pub const WS_SEQ_PREFIX: &str = "im:seq:";
pub const WS_DEDUP_PREFIX: &str = "im:dedup:";
pub const WS_RATE_PREFIX: &str = "im:rate:";
pub const WS_RECONNECT_PREFIX: &str = "im:reconnect:";
pub const WS_TOKEN_TTL_SECS: u64 = 30;
pub const WS_ONLINE_TTL_SECS: u64 = 60;
pub const WS_HEARTBEAT_INTERVAL_SECS: u64 = 30;
pub const WS_HEARTBEAT_TIMEOUT_SECS: u64 = 60;
pub const WS_MAX_IDLE_SECS: u64 = 300;
pub const WS_MAX_MESSAGE_BYTES: usize = 64 * 1024;
pub const WS_MAX_MESSAGES_PER_SEC: u32 = 100;
pub const WS_SEQ_SEGMENT_SIZE: u64 = 1024;
pub const WS_DEDUP_WINDOW_SECS: u64 = 300;
pub const WS_RECONNECT_STATE_TTL_SECS: u64 = 86400;
+52
View File
@@ -0,0 +1,52 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::queue::NatsQueue;
use super::{NatsWsBridge, WsReceiver, WsSender, WsSessionManager, WsSinkManager};
#[derive(Clone)]
pub struct WsRuntime {
sessions: Arc<WsSessionManager>,
sinks: Arc<WsSinkManager>,
bridge: NatsWsBridge,
}
impl WsRuntime {
pub fn new(queue: Arc<NatsQueue>, sessions: Arc<WsSessionManager>) -> Self {
let sinks = Arc::new(WsSinkManager::new());
let bridge = NatsWsBridge::new(queue, sessions.clone(), sinks.clone());
Self {
sessions,
sinks,
bridge,
}
}
pub fn sinks(&self) -> Arc<WsSinkManager> {
self.sinks.clone()
}
pub fn sessions(&self) -> Arc<WsSessionManager> {
self.sessions.clone()
}
pub fn attach(&self, connection_id: Uuid) -> WsReceiver {
let (tx, rx): (WsSender, WsReceiver) = WsSinkManager::channel();
self.sinks.attach(connection_id, tx);
rx
}
pub fn detach(&self, connection_id: Uuid) {
self.sinks.detach(connection_id);
self.sessions.unsubscribe_all(connection_id);
}
pub fn start_nats_bridge(&self) {
let bridge = self.bridge.clone();
tokio::spawn(async move {
bridge.run_ephemeral("im.>").await;
});
}
}
+117
View File
@@ -0,0 +1,117 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use dashmap::DashMap;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::cache::redis::AppRedis;
use crate::error::{AppError, AppResult};
use ::redis::Cmd;
use super::redis_keys::*;
struct Segment {
end: i64,
next: AtomicI64,
}
pub struct SeqAllocator {
redis: AppRedis,
segments: DashMap<Uuid, Arc<Segment>>,
locks: DashMap<Uuid, Arc<Mutex<()>>>,
segment_size: u64,
}
const MAX_RETRIES: u32 = 3;
impl SeqAllocator {
pub fn new(redis: AppRedis) -> Self {
Self {
redis,
segments: DashMap::new(),
locks: DashMap::new(),
segment_size: WS_SEQ_SEGMENT_SIZE,
}
}
pub async fn next(&self, channel_id: Uuid) -> AppResult<i64> {
for _ in 0..MAX_RETRIES {
if let Some(seq) = self.try_allocate(&channel_id) {
return Ok(seq);
}
let lock = self
.locks
.entry(channel_id)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone();
let _guard = lock.lock().await;
if let Some(seq) = self.try_allocate(&channel_id) {
return Ok(seq);
}
self.refresh(channel_id).await?;
}
Err(AppError::InternalServerError(
"seq allocation exhausted retries".into(),
))
}
pub async fn bootstrap(&self, channel_id: Uuid, db_max: i64) -> AppResult<i64> {
let key = format!("{WS_SEQ_PREFIX}{channel_id}");
let mut conn = self.redis.get_connection()?;
let current: i64 = Cmd::new()
.arg("SET")
.arg(&key)
.arg(db_max)
.arg("NX")
.arg("EX")
.arg(86400)
.query::<Option<String>>(&mut *conn.inner_mut())
.map_err(AppError::Redis)?
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| {
let existing: i64 = Cmd::new()
.arg("GET")
.arg(&key)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)
.unwrap_or(db_max);
if existing < db_max { db_max } else { existing }
});
self.segments.remove(&channel_id);
Ok(current)
}
fn try_allocate(&self, channel_id: &Uuid) -> Option<i64> {
let state = self.segments.get(channel_id)?;
let next = state.next.fetch_add(1, Ordering::Relaxed);
if next < state.end { Some(next) } else { None }
}
async fn refresh(&self, channel_id: Uuid) -> AppResult<()> {
let key = format!("{WS_SEQ_PREFIX}{channel_id}");
let mut conn = self.redis.get_connection()?;
let counter: i64 = Cmd::new()
.arg("INCRBY")
.arg(&key)
.arg(self.segment_size as i64)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
let start = counter - self.segment_size as i64 + 1;
let end = counter + 1;
self.segments.insert(
channel_id,
Arc::new(Segment {
end,
next: AtomicI64::new(start),
}),
);
let _ = Cmd::new()
.arg("EXPIRE")
.arg(&key)
.arg(86400_u64)
.query::<()>(&mut *conn.inner_mut());
Ok(())
}
}
+301
View File
@@ -0,0 +1,301 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::cache::redis::AppRedis;
use crate::error::{AppError, AppResult};
use crate::queue::NatsQueue;
use ::redis::Cmd;
use super::redis_keys::*;
use super::session_redis::{heartbeat_redis, register_redis_online, unregister_redis_online};
use super::typing;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WsSessionState {
Connecting,
Authenticated,
Replaced,
Closing,
Closed,
}
impl WsSessionState {
pub fn is_deliverable(self) -> bool {
matches!(self, Self::Authenticated)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsSession {
pub user_id: Uuid,
pub device_id: String,
pub connection_id: Uuid,
pub workspace_name: String,
pub connected_at: i64,
pub authenticated_at: Option<i64>,
pub state: WsSessionState,
pub superseded_by: Option<Uuid>,
}
#[derive(Clone)]
pub struct WsSessionManager {
redis: AppRedis,
#[allow(dead_code)]
nats: Arc<NatsQueue>,
user_devices: Arc<DashMap<Uuid, HashMap<String, Uuid>>>,
sessions: Arc<DashMap<Uuid, WsSession>>,
channel_routes: Arc<DashMap<Uuid, HashSet<Uuid>>>,
session_channels: Arc<DashMap<Uuid, HashSet<Uuid>>>,
}
impl WsSessionManager {
pub fn new(redis: AppRedis, nats: Arc<NatsQueue>) -> Self {
Self {
redis,
nats,
user_devices: Arc::new(DashMap::new()),
sessions: Arc::new(DashMap::new()),
channel_routes: Arc::new(DashMap::new()),
session_channels: Arc::new(DashMap::new()),
}
}
pub fn issue_token(&self, user_id: Uuid, workspace_name: &str) -> AppResult<String> {
self.issue_token_for_device(user_id, workspace_name, "default")
}
pub fn issue_token_for_device(
&self,
user_id: Uuid,
workspace_name: &str,
device_id: &str,
) -> AppResult<String> {
let token = format!("ws_{}", Uuid::now_v7());
let session = WsSession {
user_id,
device_id: device_id.to_string(),
connection_id: Uuid::nil(),
workspace_name: workspace_name.to_string(),
connected_at: 0,
authenticated_at: None,
state: WsSessionState::Connecting,
superseded_by: None,
};
let json = serde_json::to_string(&session)?;
let key = format!("{WS_TOKEN_PREFIX}{token}");
let mut conn = self.redis.get_connection()?;
Cmd::new()
.arg("SETEX")
.arg(&key)
.arg(WS_TOKEN_TTL_SECS)
.arg(&json)
.query::<()>(&mut *conn.inner_mut())?;
Ok(token)
}
pub fn redeem_token(&self, token: &str) -> AppResult<WsSession> {
let key = format!("{WS_TOKEN_PREFIX}{token}");
let mut conn = self.redis.get_connection()?;
let json: Option<String> = Cmd::new()
.arg("GETDEL")
.arg(&key)
.query::<Option<String>>(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
let json = json.ok_or(AppError::Unauthorized)?;
let mut session: WsSession = serde_json::from_str(&json)
.map_err(|e| AppError::Config(format!("invalid ws session: {e}")))?;
let now = chrono::Utc::now().timestamp_millis();
session.connection_id = Uuid::now_v7();
session.connected_at = now;
session.authenticated_at = Some(now);
session.state = WsSessionState::Authenticated;
session.superseded_by = None;
Ok(session)
}
pub fn register_connection(&self, session: &WsSession) -> AppResult<()> {
let _ = self.register_connection_with_replacement(session)?;
Ok(())
}
pub fn register_connection_with_replacement(
&self,
session: &WsSession,
) -> AppResult<Option<Uuid>> {
let mut current = session.clone();
current.state = WsSessionState::Authenticated;
current.superseded_by = None;
self.sessions.insert(current.connection_id, current.clone());
let replaced = {
let mut entry = self.user_devices.entry(current.user_id).or_default();
entry.insert(current.device_id.clone(), current.connection_id)
};
if let Some(old_id) = replaced
&& old_id != current.connection_id
{
if let Some(mut old) = self.sessions.get_mut(&old_id) {
old.state = WsSessionState::Replaced;
old.superseded_by = Some(current.connection_id);
}
self.unsubscribe_all(old_id);
}
register_redis_online(&self.redis, &current)?;
Ok(replaced.filter(|old| *old != current.connection_id))
}
pub fn unregister_connection(&self, session: &WsSession) -> AppResult<()> {
let removed = self.sessions.remove(&session.connection_id).map(|(_, s)| s);
let current = removed.as_ref().unwrap_or(session);
self.unsubscribe_all(current.connection_id);
if let Some(mut devices) = self.user_devices.get_mut(&current.user_id)
&& devices.get(&current.device_id).copied() == Some(current.connection_id)
{
devices.remove(&current.device_id);
}
self.user_devices
.remove_if(&current.user_id, |_, devices| devices.is_empty());
unregister_redis_online(&self.redis, current)
}
pub fn heartbeat(&self, session: &WsSession) -> AppResult<()> {
if !self.is_deliverable(session.connection_id) {
return Err(AppError::Unauthorized);
}
heartbeat_redis(&self.redis, session)
}
pub fn subscribe_channel(&self, connection_id: Uuid, channel_id: Uuid) {
self.channel_routes
.entry(channel_id)
.or_default()
.insert(connection_id);
self.session_channels
.entry(connection_id)
.or_default()
.insert(channel_id);
}
pub fn unsubscribe_channel(&self, connection_id: Uuid, channel_id: Uuid) {
if let Some(mut sessions) = self.channel_routes.get_mut(&channel_id) {
sessions.remove(&connection_id);
}
self.channel_routes
.remove_if(&channel_id, |_, sessions| sessions.is_empty());
if let Some(mut channels) = self.session_channels.get_mut(&connection_id) {
channels.remove(&channel_id);
}
self.session_channels
.remove_if(&connection_id, |_, channels| channels.is_empty());
}
pub fn unsubscribe_all(&self, connection_id: Uuid) {
let channels = self
.session_channels
.remove(&connection_id)
.map(|(_, channels)| channels)
.unwrap_or_default();
for channel_id in channels {
if let Some(mut sessions) = self.channel_routes.get_mut(&channel_id) {
sessions.remove(&connection_id);
}
self.channel_routes
.remove_if(&channel_id, |_, sessions| sessions.is_empty());
}
}
pub fn subscribers(&self, channel_id: Uuid) -> Vec<Uuid> {
self.channel_routes
.get(&channel_id)
.map(|sessions| sessions.iter().copied().collect())
.unwrap_or_default()
}
pub fn user_connections(&self, user_id: Uuid) -> Vec<Uuid> {
self.user_devices
.get(&user_id)
.map(|devices| devices.values().copied().collect())
.unwrap_or_default()
}
pub fn workspace_connections(&self, workspace_name: &str) -> Vec<Uuid> {
self.sessions
.iter()
.filter_map(|entry| {
let session = entry.value();
(session.workspace_name == workspace_name && session.state.is_deliverable())
.then_some(session.connection_id)
})
.collect()
}
pub fn get_session(&self, connection_id: Uuid) -> Option<WsSession> {
self.sessions
.get(&connection_id)
.map(|session| session.clone())
}
pub fn is_deliverable(&self, connection_id: Uuid) -> bool {
self.sessions
.get(&connection_id)
.map(|session| session.state.is_deliverable() && session.superseded_by.is_none())
.unwrap_or(false)
}
pub fn is_user_online(&self, user_id: Uuid) -> AppResult<bool> {
Ok(self
.user_devices
.get(&user_id)
.map(|devices| !devices.is_empty())
.unwrap_or(false))
}
pub fn get_connection_count(&self, user_id: Uuid) -> AppResult<u32> {
Ok(self
.user_devices
.get(&user_id)
.map(|devices| devices.len() as u32)
.unwrap_or(0))
}
pub fn set_typing(
&self,
channel_id: Uuid,
thread_id: Option<Uuid>,
user_id: Uuid,
) -> AppResult<()> {
typing::set_typing(&self.redis, channel_id, thread_id, user_id)
}
pub fn clear_typing(
&self,
channel_id: Uuid,
thread_id: Option<Uuid>,
user_id: Uuid,
) -> AppResult<()> {
typing::clear_typing(&self.redis, channel_id, thread_id, user_id)
}
pub fn get_typing_users(
&self,
channel_id: Uuid,
thread_id: Option<Uuid>,
) -> AppResult<Vec<Uuid>> {
typing::get_typing_users(&self.redis, channel_id, thread_id)
}
pub fn heartbeat_interval(&self) -> Duration {
Duration::from_secs(WS_HEARTBEAT_INTERVAL_SECS)
}
pub fn heartbeat_interval_secs(&self) -> u64 {
WS_HEARTBEAT_INTERVAL_SECS
}
}
+93
View File
@@ -0,0 +1,93 @@
use crate::cache::redis::AppRedis;
use crate::error::{AppError, AppResult};
use crate::service::im::util::PRESENCE_PREFIX;
use ::redis::Cmd;
use super::redis_keys::*;
use super::session::WsSession;
pub fn register_redis_online(redis: &AppRedis, session: &WsSession) -> AppResult<()> {
let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id);
let conn_id = session.connection_id.to_string();
let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id);
let mut conn = redis.get_connection()?;
Cmd::new()
.arg("SADD")
.arg(&set_key)
.arg(&conn_id)
.query::<i32>(&mut *conn.inner_mut())?;
Cmd::new()
.arg("EXPIRE")
.arg(&set_key)
.arg(WS_ONLINE_TTL_SECS)
.query::<()>(&mut *conn.inner_mut())?;
Cmd::new()
.arg("SETEX")
.arg(&meta_key)
.arg(WS_ONLINE_TTL_SECS)
.arg(session.workspace_name.as_str())
.query::<()>(&mut *conn.inner_mut())?;
Ok(())
}
pub fn unregister_redis_online(redis: &AppRedis, session: &WsSession) -> AppResult<()> {
let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id);
let conn_id = session.connection_id.to_string();
let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id);
let mut conn = redis.get_connection()?;
Cmd::new()
.arg("SREM")
.arg(&set_key)
.arg(&conn_id)
.query::<i32>(&mut *conn.inner_mut())?;
let remaining: i32 = Cmd::new()
.arg("SCARD")
.arg(&set_key)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
if remaining == 0 {
Cmd::new()
.arg("DEL")
.arg(&set_key)
.query::<()>(&mut *conn.inner_mut())?;
let pk = format!("{PRESENCE_PREFIX}{}", session.user_id);
let _ = Cmd::new()
.arg("DEL")
.arg(&pk)
.query::<()>(&mut *conn.inner_mut());
}
let _ = Cmd::new()
.arg("DEL")
.arg(&meta_key)
.query::<()>(&mut *conn.inner_mut());
Ok(())
}
pub fn heartbeat_redis(redis: &AppRedis, session: &WsSession) -> AppResult<()> {
let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id);
let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id);
let pk = format!("{PRESENCE_PREFIX}{}", session.user_id);
let mut conn = redis.get_connection()?;
let _ = Cmd::new()
.arg("EXPIRE")
.arg(&set_key)
.arg(WS_ONLINE_TTL_SECS)
.query::<()>(&mut *conn.inner_mut());
let _ = Cmd::new()
.arg("SETEX")
.arg(&meta_key)
.arg(WS_ONLINE_TTL_SECS)
.arg(session.workspace_name.as_str())
.query::<()>(&mut *conn.inner_mut());
let _ = Cmd::new()
.arg("SETEX")
.arg(&pk)
.arg(WS_ONLINE_TTL_SECS)
.arg("online")
.query::<()>(&mut *conn.inner_mut());
Ok(())
}
+53
View File
@@ -0,0 +1,53 @@
use std::sync::Arc;
use dashmap::DashMap;
use tokio::sync::mpsc;
use uuid::Uuid;
use super::WsOutbound;
pub type WsSender = mpsc::UnboundedSender<WsOutbound>;
pub type WsReceiver = mpsc::UnboundedReceiver<WsOutbound>;
#[derive(Clone, Default)]
pub struct WsSinkManager {
sinks: Arc<DashMap<Uuid, WsSender>>,
}
impl WsSinkManager {
pub fn new() -> Self {
Self::default()
}
pub fn channel() -> (WsSender, WsReceiver) {
mpsc::unbounded_channel()
}
pub fn attach(&self, connection_id: Uuid, sender: WsSender) {
self.sinks.insert(connection_id, sender);
}
pub fn detach(&self, connection_id: Uuid) {
self.sinks.remove(&connection_id);
}
pub fn send(&self, connection_id: Uuid, message: WsOutbound) -> bool {
self.sinks
.get(&connection_id)
.map(|sink| sink.send(message).is_ok())
.unwrap_or(false)
}
pub fn send_many<I>(&self, ids: I, message: WsOutbound) -> usize
where
I: IntoIterator<Item = Uuid>,
{
ids.into_iter()
.filter(|id| self.send(*id, message.clone()))
.count()
}
pub fn contains(&self, connection_id: Uuid) -> bool {
self.sinks.contains_key(&connection_id)
}
}
+71
View File
@@ -0,0 +1,71 @@
use uuid::Uuid;
use crate::cache::redis::AppRedis;
use crate::error::{AppError, AppResult};
use crate::service::im::util::{TYPING_PREFIX, TYPING_TTL_SECS};
use ::redis::Cmd;
pub fn set_typing(
redis: &AppRedis,
channel_id: Uuid,
thread_id: Option<Uuid>,
user_id: Uuid,
) -> AppResult<()> {
let key = typing_key(channel_id, thread_id, user_id);
let mut conn = redis.get_connection()?;
Cmd::new()
.arg("SETEX")
.arg(&key)
.arg(TYPING_TTL_SECS as u64)
.arg("1")
.query::<()>(&mut *conn.inner_mut())?;
Ok(())
}
pub fn clear_typing(
redis: &AppRedis,
channel_id: Uuid,
thread_id: Option<Uuid>,
user_id: Uuid,
) -> AppResult<()> {
let key = typing_key(channel_id, thread_id, user_id);
let mut conn = redis.get_connection()?;
Cmd::new()
.arg("DEL")
.arg(&key)
.query::<()>(&mut *conn.inner_mut())?;
Ok(())
}
pub fn get_typing_users(
redis: &AppRedis,
channel_id: Uuid,
thread_id: Option<Uuid>,
) -> AppResult<Vec<Uuid>> {
let pattern = match thread_id {
Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:*"),
None => format!("{TYPING_PREFIX}{channel_id}:*"),
};
let mut conn = redis.get_connection()?;
let keys: Vec<String> = Cmd::new()
.arg("KEYS")
.arg(&pattern)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
let mut ids = Vec::with_capacity(keys.len());
for key in &keys {
if let Some(part) = key.rsplit(':').next()
&& let Ok(uid) = part.parse::<Uuid>()
{
ids.push(uid);
}
}
Ok(ids)
}
fn typing_key(channel_id: Uuid, thread_id: Option<Uuid>, user_id: Uuid) -> String {
match thread_id {
Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:{user_id}"),
None => format!("{TYPING_PREFIX}{channel_id}:{user_id}"),
}
}
+12
View File
@@ -0,0 +1,12 @@
pub mod cache;
pub mod config;
pub mod error;
pub mod etcd;
pub mod immediate;
pub mod models;
pub mod pb;
pub mod queue;
pub mod service;
pub mod session;
pub mod storage;
pub mod api;
+3
View File
@@ -0,0 +1,3 @@
fn main() {
println!("Hello, world!");
}
+2107
View File
File diff suppressed because it is too large Load Diff
+667
View File
@@ -0,0 +1,667 @@
-- ============================================================
-- Migration: 002_triggers.sql
-- Automated: updated_at, deleted_at, event recording, audit, security, stats
--
-- Usage:
-- Application sets session variable before writes:
-- SET LOCAL app.current_user_id = '00000000-0000-0000-0000-000000000001';
-- Triggers read it for actor_id in events / audit / security logs.
-- ============================================================
-- ============================================================
-- 1. set_updated_at — auto-refresh updated_at on any UPDATE
-- Applied to ALL tables with an updated_at column.
-- ============================================================
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Tables with updated_at (generated from models/)
DROP TRIGGER IF EXISTS trg_agent_updated_at ON agent;
CREATE TRIGGER trg_agent_updated_at BEFORE UPDATE ON agent FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_agent_event_subscription_updated_at ON agent_event_subscription;
CREATE TRIGGER trg_agent_event_subscription_updated_at BEFORE UPDATE ON agent_event_subscription FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_agent_execution_updated_at ON agent_execution;
CREATE TRIGGER trg_agent_execution_updated_at BEFORE UPDATE ON agent_execution FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_agent_feedback_updated_at ON agent_feedback;
CREATE TRIGGER trg_agent_feedback_updated_at BEFORE UPDATE ON agent_feedback FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_agent_schedule_updated_at ON agent_schedule;
CREATE TRIGGER trg_agent_schedule_updated_at BEFORE UPDATE ON agent_schedule FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_agent_version_updated_at ON agent_version;
CREATE TRIGGER trg_agent_version_updated_at BEFORE UPDATE ON agent_version FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_agent_workspace_binding_updated_at ON agent_workspace_binding;
CREATE TRIGGER trg_agent_workspace_binding_updated_at BEFORE UPDATE ON agent_workspace_binding FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_ai_model_updated_at ON ai_model;
CREATE TRIGGER trg_ai_model_updated_at BEFORE UPDATE ON ai_model FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_ai_model_capability_updated_at ON ai_model_capability;
CREATE TRIGGER trg_ai_model_capability_updated_at BEFORE UPDATE ON ai_model_capability FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_ai_model_version_updated_at ON ai_model_version;
CREATE TRIGGER trg_ai_model_version_updated_at BEFORE UPDATE ON ai_model_version FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_channel_updated_at ON channel;
CREATE TRIGGER trg_channel_updated_at BEFORE UPDATE ON channel FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_channel_member_updated_at ON channel_member;
CREATE TRIGGER trg_channel_member_updated_at BEFORE UPDATE ON channel_member FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_channel_member_role_updated_at ON channel_member_role;
CREATE TRIGGER trg_channel_member_role_updated_at BEFORE UPDATE ON channel_member_role FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_channel_repo_link_updated_at ON channel_repo_link;
CREATE TRIGGER trg_channel_repo_link_updated_at BEFORE UPDATE ON channel_repo_link FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_channel_slash_command_updated_at ON channel_slash_command;
CREATE TRIGGER trg_channel_slash_command_updated_at BEFORE UPDATE ON channel_slash_command FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_channel_stats_updated_at ON channel_stats;
CREATE TRIGGER trg_channel_stats_updated_at BEFORE UPDATE ON channel_stats FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_channel_webhook_updated_at ON channel_webhook;
CREATE TRIGGER trg_channel_webhook_updated_at BEFORE UPDATE ON channel_webhook FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_conversation_updated_at ON conversation;
CREATE TRIGGER trg_conversation_updated_at BEFORE UPDATE ON conversation FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_conversation_bookmark_updated_at ON conversation_bookmark;
CREATE TRIGGER trg_conversation_bookmark_updated_at BEFORE UPDATE ON conversation_bookmark FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_conversation_message_updated_at ON conversation_message;
CREATE TRIGGER trg_conversation_message_updated_at BEFORE UPDATE ON conversation_message FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_conversation_participant_updated_at ON conversation_participant;
CREATE TRIGGER trg_conversation_participant_updated_at BEFORE UPDATE ON conversation_participant FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_conversation_summary_updated_at ON conversation_summary;
CREATE TRIGGER trg_conversation_summary_updated_at BEFORE UPDATE ON conversation_summary FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_conversation_tool_call_updated_at ON conversation_tool_call;
CREATE TRIGGER trg_conversation_tool_call_updated_at BEFORE UPDATE ON conversation_tool_call FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_issue_updated_at ON issue;
CREATE TRIGGER trg_issue_updated_at BEFORE UPDATE ON issue FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_issue_comment_updated_at ON issue_comment;
CREATE TRIGGER trg_issue_comment_updated_at BEFORE UPDATE ON issue_comment FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_issue_label_updated_at ON issue_label;
CREATE TRIGGER trg_issue_label_updated_at BEFORE UPDATE ON issue_label FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_issue_milestone_updated_at ON issue_milestone;
CREATE TRIGGER trg_issue_milestone_updated_at BEFORE UPDATE ON issue_milestone FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_issue_reminder_updated_at ON issue_reminder;
CREATE TRIGGER trg_issue_reminder_updated_at BEFORE UPDATE ON issue_reminder FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_issue_stats_updated_at ON issue_stats;
CREATE TRIGGER trg_issue_stats_updated_at BEFORE UPDATE ON issue_stats FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_issue_subscriber_updated_at ON issue_subscriber;
CREATE TRIGGER trg_issue_subscriber_updated_at BEFORE UPDATE ON issue_subscriber FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_issue_template_updated_at ON issue_template;
CREATE TRIGGER trg_issue_template_updated_at BEFORE UPDATE ON issue_template FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_message_updated_at ON message;
CREATE TRIGGER trg_message_updated_at BEFORE UPDATE ON message FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_message_bookmark_updated_at ON message_bookmark;
CREATE TRIGGER trg_message_bookmark_updated_at BEFORE UPDATE ON message_bookmark FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_message_thread_updated_at ON message_thread;
CREATE TRIGGER trg_message_thread_updated_at BEFORE UPDATE ON message_thread FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_notification_updated_at ON notification;
CREATE TRIGGER trg_notification_updated_at BEFORE UPDATE ON notification FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_notification_block_updated_at ON notification_block;
CREATE TRIGGER trg_notification_block_updated_at BEFORE UPDATE ON notification_block FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_notification_delivery_updated_at ON notification_delivery;
CREATE TRIGGER trg_notification_delivery_updated_at BEFORE UPDATE ON notification_delivery FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_notification_subscription_updated_at ON notification_subscription;
CREATE TRIGGER trg_notification_subscription_updated_at BEFORE UPDATE ON notification_subscription FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_notification_template_updated_at ON notification_template;
CREATE TRIGGER trg_notification_template_updated_at BEFORE UPDATE ON notification_template FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_pr_check_run_updated_at ON pr_check_run;
CREATE TRIGGER trg_pr_check_run_updated_at BEFORE UPDATE ON pr_check_run FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_pr_file_updated_at ON pr_file;
CREATE TRIGGER trg_pr_file_updated_at BEFORE UPDATE ON pr_file FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_pr_label_updated_at ON pr_label;
CREATE TRIGGER trg_pr_label_updated_at BEFORE UPDATE ON pr_label FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_pr_merge_strategy_updated_at ON pr_merge_strategy;
CREATE TRIGGER trg_pr_merge_strategy_updated_at BEFORE UPDATE ON pr_merge_strategy FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_pr_status_updated_at ON pr_status;
CREATE TRIGGER trg_pr_status_updated_at BEFORE UPDATE ON pr_status FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_pr_subscription_updated_at ON pr_subscription;
CREATE TRIGGER trg_pr_subscription_updated_at BEFORE UPDATE ON pr_subscription FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_pull_request_updated_at ON pull_request;
CREATE TRIGGER trg_pull_request_updated_at BEFORE UPDATE ON pull_request FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_repo_updated_at ON repo;
CREATE TRIGGER trg_repo_updated_at BEFORE UPDATE ON repo FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_repo_branch_updated_at ON repo_branch;
CREATE TRIGGER trg_repo_branch_updated_at BEFORE UPDATE ON repo_branch FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_repo_commit_comment_updated_at ON repo_commit_comment;
CREATE TRIGGER trg_repo_commit_comment_updated_at BEFORE UPDATE ON repo_commit_comment FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_repo_commit_status_updated_at ON repo_commit_status;
CREATE TRIGGER trg_repo_commit_status_updated_at BEFORE UPDATE ON repo_commit_status FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_repo_deploy_key_updated_at ON repo_deploy_key;
CREATE TRIGGER trg_repo_deploy_key_updated_at BEFORE UPDATE ON repo_deploy_key FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_repo_member_updated_at ON repo_member;
CREATE TRIGGER trg_repo_member_updated_at BEFORE UPDATE ON repo_member FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_repo_push_lock_updated_at ON repo_push_lock;
CREATE TRIGGER trg_repo_push_lock_updated_at BEFORE UPDATE ON repo_push_lock FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_repo_release_updated_at ON repo_release;
CREATE TRIGGER trg_repo_release_updated_at BEFORE UPDATE ON repo_release FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_repo_stats_updated_at ON repo_stats;
CREATE TRIGGER trg_repo_stats_updated_at BEFORE UPDATE ON repo_stats FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_repo_watch_updated_at ON repo_watch;
CREATE TRIGGER trg_repo_watch_updated_at BEFORE UPDATE ON repo_watch FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_repo_webhook_updated_at ON repo_webhook;
CREATE TRIGGER trg_repo_webhook_updated_at BEFORE UPDATE ON repo_webhook FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_user_updated_at ON "user";
CREATE TRIGGER trg_user_updated_at BEFORE UPDATE ON "user" FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_user_appearance_updated_at ON user_appearance;
CREATE TRIGGER trg_user_appearance_updated_at BEFORE UPDATE ON user_appearance FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_user_device_updated_at ON user_device;
CREATE TRIGGER trg_user_device_updated_at BEFORE UPDATE ON user_device FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_user_gpg_key_updated_at ON user_gpg_key;
CREATE TRIGGER trg_user_gpg_key_updated_at BEFORE UPDATE ON user_gpg_key FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_user_mail_updated_at ON user_mail;
CREATE TRIGGER trg_user_mail_updated_at BEFORE UPDATE ON user_mail FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_user_notify_setting_updated_at ON user_notify_setting;
CREATE TRIGGER trg_user_notify_setting_updated_at BEFORE UPDATE ON user_notify_setting FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_user_password_updated_at ON user_password;
CREATE TRIGGER trg_user_password_updated_at BEFORE UPDATE ON user_password FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_user_personal_access_token_updated_at ON user_personal_access_token;
CREATE TRIGGER trg_user_personal_access_token_updated_at BEFORE UPDATE ON user_personal_access_token FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_user_profile_updated_at ON user_profile;
CREATE TRIGGER trg_user_profile_updated_at BEFORE UPDATE ON user_profile FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_user_ssh_key_updated_at ON user_ssh_key;
CREATE TRIGGER trg_user_ssh_key_updated_at BEFORE UPDATE ON user_ssh_key FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_workspace_updated_at ON workspace;
CREATE TRIGGER trg_workspace_updated_at BEFORE UPDATE ON workspace FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_workspace_billing_updated_at ON workspace_billing;
CREATE TRIGGER trg_workspace_billing_updated_at BEFORE UPDATE ON workspace_billing FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_workspace_custom_branding_updated_at ON workspace_custom_branding;
CREATE TRIGGER trg_workspace_custom_branding_updated_at BEFORE UPDATE ON workspace_custom_branding FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_workspace_domain_updated_at ON workspace_domain;
CREATE TRIGGER trg_workspace_domain_updated_at BEFORE UPDATE ON workspace_domain FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_workspace_integration_updated_at ON workspace_integration;
CREATE TRIGGER trg_workspace_integration_updated_at BEFORE UPDATE ON workspace_integration FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_workspace_member_updated_at ON workspace_member;
CREATE TRIGGER trg_workspace_member_updated_at BEFORE UPDATE ON workspace_member FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_workspace_pending_approval_updated_at ON workspace_pending_approval;
CREATE TRIGGER trg_workspace_pending_approval_updated_at BEFORE UPDATE ON workspace_pending_approval FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_workspace_settings_updated_at ON workspace_settings;
CREATE TRIGGER trg_workspace_settings_updated_at BEFORE UPDATE ON workspace_settings FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_workspace_stats_updated_at ON workspace_stats;
CREATE TRIGGER trg_workspace_stats_updated_at BEFORE UPDATE ON workspace_stats FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_workspace_webhook_updated_at ON workspace_webhook;
CREATE TRIGGER trg_workspace_webhook_updated_at BEFORE UPDATE ON workspace_webhook FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ============================================================
-- 2. set_deleted_at — auto-timestamp soft delete
-- Fires when deleted_at transitions NULL → NOT NULL.
-- Works for ANY table with a deleted_at column (no status dependency).
-- ============================================================
CREATE OR REPLACE FUNCTION set_deleted_at()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN
NEW.deleted_at = NOW();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Only tables that contain BOTH status AND deleted_at columns
-- Verified from models: user, repo, workspace, issue, pull_request, channel,
-- conversation, conversation_message, agent, message, issue_comment,
-- repo_commit_comment, repo_release, notification, ai_model, conversation_attachment
DROP TRIGGER IF EXISTS trg_user_deleted_at ON "user";
CREATE TRIGGER trg_user_deleted_at BEFORE UPDATE ON "user" FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_repo_deleted_at ON repo;
CREATE TRIGGER trg_repo_deleted_at BEFORE UPDATE ON repo FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_workspace_deleted_at ON workspace;
CREATE TRIGGER trg_workspace_deleted_at BEFORE UPDATE ON workspace FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_issue_deleted_at ON issue;
CREATE TRIGGER trg_issue_deleted_at BEFORE UPDATE ON issue FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_pull_request_deleted_at ON pull_request;
CREATE TRIGGER trg_pull_request_deleted_at BEFORE UPDATE ON pull_request FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_channel_deleted_at ON channel;
CREATE TRIGGER trg_channel_deleted_at BEFORE UPDATE ON channel FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_conversation_deleted_at ON conversation;
CREATE TRIGGER trg_conversation_deleted_at BEFORE UPDATE ON conversation FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_conversation_message_deleted_at ON conversation_message;
CREATE TRIGGER trg_conversation_message_deleted_at BEFORE UPDATE ON conversation_message FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_agent_deleted_at ON agent;
CREATE TRIGGER trg_agent_deleted_at BEFORE UPDATE ON agent FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_message_deleted_at ON message;
CREATE TRIGGER trg_message_deleted_at BEFORE UPDATE ON message FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_issue_comment_deleted_at ON issue_comment;
CREATE TRIGGER trg_issue_comment_deleted_at BEFORE UPDATE ON issue_comment FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_repo_commit_comment_deleted_at ON repo_commit_comment;
CREATE TRIGGER trg_repo_commit_comment_deleted_at BEFORE UPDATE ON repo_commit_comment FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_repo_release_deleted_at ON repo_release;
CREATE TRIGGER trg_repo_release_deleted_at BEFORE UPDATE ON repo_release FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_notification_deleted_at ON notification;
CREATE TRIGGER trg_notification_deleted_at BEFORE UPDATE ON notification FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_ai_model_deleted_at ON ai_model;
CREATE TRIGGER trg_ai_model_deleted_at BEFORE UPDATE ON ai_model FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
DROP TRIGGER IF EXISTS trg_conversation_attachment_deleted_at ON conversation_attachment;
CREATE TRIGGER trg_conversation_attachment_deleted_at BEFORE UPDATE ON conversation_attachment FOR EACH ROW EXECUTE FUNCTION set_deleted_at();
-- ============================================================
-- 3. Helper: resolve current user from session variable
-- Application must SET LOCAL app.current_user_id = '...' before writes.
-- ============================================================
CREATE OR REPLACE FUNCTION app_current_user_id()
RETURNS UUID AS $$
BEGIN
RETURN current_setting('app.current_user_id', true)::UUID;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;
-- ============================================================
-- 4. Event recording — issue / pull_request / channel
-- Tracks: created, renamed, state_changed, priority_changed,
-- draft_toggled, archived, restored.
-- ============================================================
-- 4a. issue events
CREATE OR REPLACE FUNCTION record_issue_event()
RETURNS TRIGGER AS $$
DECLARE
actor UUID := app_current_user_id();
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO issue_event (issue_id, actor_id, event_type, created_at)
VALUES (NEW.id, COALESCE(actor, NEW.author_id), 'created', NOW());
RETURN NEW;
END IF;
IF NEW.title IS DISTINCT FROM OLD.title THEN
INSERT INTO issue_event (issue_id, actor_id, event_type, old_value, new_value, created_at)
VALUES (NEW.id, actor, 'renamed', OLD.title, NEW.title, NOW());
END IF;
IF NEW.state IS DISTINCT FROM OLD.state THEN
INSERT INTO issue_event (issue_id, actor_id, event_type, old_value, new_value, created_at)
VALUES (NEW.id, actor, 'state_changed', OLD.state, NEW.state, NOW());
END IF;
IF NEW.priority IS DISTINCT FROM OLD.priority THEN
INSERT INTO issue_event (issue_id, actor_id, event_type, old_value, new_value, created_at)
VALUES (NEW.id, actor, 'priority_changed', OLD.priority, NEW.priority, NOW());
END IF;
IF NEW.body IS DISTINCT FROM OLD.body THEN
INSERT INTO issue_event (issue_id, actor_id, event_type, created_at)
VALUES (NEW.id, actor, 'body_updated', NOW());
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_issue_event ON issue;
CREATE TRIGGER trg_issue_event
AFTER INSERT OR UPDATE ON issue
FOR EACH ROW EXECUTE FUNCTION record_issue_event();
-- 4b. pull_request events
CREATE OR REPLACE FUNCTION record_pr_event()
RETURNS TRIGGER AS $$
DECLARE
actor UUID := app_current_user_id();
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO pr_event (pull_request_id, actor_id, event_type, created_at)
VALUES (NEW.id, COALESCE(actor, NEW.author_id), 'created', NOW());
RETURN NEW;
END IF;
IF NEW.title IS DISTINCT FROM OLD.title THEN
INSERT INTO pr_event (pull_request_id, actor_id, event_type, old_value, new_value, created_at)
VALUES (NEW.id, actor, 'renamed', OLD.title, NEW.title, NOW());
END IF;
IF NEW.state IS DISTINCT FROM OLD.state THEN
INSERT INTO pr_event (pull_request_id, actor_id, event_type, old_value, new_value, created_at)
VALUES (NEW.id, actor, 'state_changed', OLD.state, NEW.state, NOW());
END IF;
IF NEW.draft IS DISTINCT FROM OLD.draft THEN
INSERT INTO pr_event (pull_request_id, actor_id, event_type, old_value, new_value, created_at)
VALUES (NEW.id, actor, 'draft_toggled', OLD.draft::text, NEW.draft::text, NOW());
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_pr_event ON pull_request;
CREATE TRIGGER trg_pr_event
AFTER INSERT OR UPDATE ON pull_request
FOR EACH ROW EXECUTE FUNCTION record_pr_event();
-- 4c. channel events
CREATE OR REPLACE FUNCTION record_channel_event()
RETURNS TRIGGER AS $$
DECLARE
actor UUID := app_current_user_id();
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO channel_event (channel_id, actor_id, event_type, created_at)
VALUES (NEW.id, COALESCE(actor, NEW.created_by), 'created', NOW());
RETURN NEW;
END IF;
IF NEW.name IS DISTINCT FROM OLD.name THEN
INSERT INTO channel_event (channel_id, actor_id, event_type, old_value, new_value, created_at)
VALUES (NEW.id, actor, 'renamed', OLD.name, NEW.name, NOW());
END IF;
IF NEW.archived IS DISTINCT FROM OLD.archived THEN
INSERT INTO channel_event (channel_id, actor_id, event_type, old_value, new_value, created_at)
VALUES (NEW.id, actor, 'archive_toggled', OLD.archived::text, NEW.archived::text, NOW());
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_channel_event ON channel;
CREATE TRIGGER trg_channel_event
AFTER INSERT OR UPDATE ON channel
FOR EACH ROW EXECUTE FUNCTION record_channel_event();
-- ============================================================
-- 5. Workspace audit log
-- Only applied to tables that directly own a workspace_id column.
-- actor_id resolved from app.current_user_id session variable.
-- ============================================================
-- Tables with workspace_id (verified from models):
-- repo, channel, channel_invitation, channel_slash_command,
-- agent, agent_event_subscription, agent_execution, agent_schedule,
-- agent_workspace_binding, conversation,
-- notification, notification_block, notification_subscription,
-- workspace_billing, workspace_custom_branding, workspace_domain,
-- workspace_integration, workspace_invitation, workspace_member,
-- workspace_pending_approval, workspace_settings, workspace_webhook
CREATE OR REPLACE FUNCTION record_workspace_audit()
RETURNS TRIGGER AS $$
DECLARE
ws_id UUID;
actor UUID := app_current_user_id();
action_text TEXT;
BEGIN
ws_id := COALESCE(NEW.workspace_id, OLD.workspace_id);
IF ws_id IS NULL THEN
RETURN COALESCE(NEW, OLD);
END IF;
action_text := CASE TG_OP
WHEN 'INSERT' THEN 'created'
WHEN 'UPDATE' THEN 'updated'
WHEN 'DELETE' THEN 'deleted'
END;
INSERT INTO workspace_audit_log (workspace_id, actor_id, action, target_type, target_id, created_at)
VALUES (ws_id, actor, action_text, TG_TABLE_NAME, COALESCE(NEW.id, OLD.id), NOW());
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
-- Apply to tables with workspace_id column
DROP TRIGGER IF EXISTS trg_repo_audit ON repo;
CREATE TRIGGER trg_repo_audit AFTER INSERT OR UPDATE OR DELETE ON repo FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_channel_audit ON channel;
CREATE TRIGGER trg_channel_audit AFTER INSERT OR UPDATE OR DELETE ON channel FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_channel_slash_command_audit ON channel_slash_command;
CREATE TRIGGER trg_channel_slash_command_audit AFTER INSERT OR UPDATE OR DELETE ON channel_slash_command FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_agent_audit ON agent;
CREATE TRIGGER trg_agent_audit AFTER INSERT OR UPDATE OR DELETE ON agent FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_agent_schedule_audit ON agent_schedule;
CREATE TRIGGER trg_agent_schedule_audit AFTER INSERT OR UPDATE OR DELETE ON agent_schedule FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_agent_workspace_binding_audit ON agent_workspace_binding;
CREATE TRIGGER trg_agent_workspace_binding_audit AFTER INSERT OR UPDATE OR DELETE ON agent_workspace_binding FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_conversation_audit ON conversation;
CREATE TRIGGER trg_conversation_audit AFTER INSERT OR UPDATE OR DELETE ON conversation FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_channel_invitation_audit ON channel_invitation;
CREATE TRIGGER trg_channel_invitation_audit AFTER INSERT OR UPDATE OR DELETE ON channel_invitation FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_agent_event_subscription_audit ON agent_event_subscription;
CREATE TRIGGER trg_agent_event_subscription_audit AFTER INSERT OR UPDATE OR DELETE ON agent_event_subscription FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_agent_execution_audit ON agent_execution;
CREATE TRIGGER trg_agent_execution_audit AFTER INSERT OR UPDATE OR DELETE ON agent_execution FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_notification_audit ON notification;
CREATE TRIGGER trg_notification_audit AFTER INSERT OR UPDATE OR DELETE ON notification FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_notification_block_audit ON notification_block;
CREATE TRIGGER trg_notification_block_audit AFTER INSERT OR UPDATE OR DELETE ON notification_block FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_notification_subscription_audit ON notification_subscription;
CREATE TRIGGER trg_notification_subscription_audit AFTER INSERT OR UPDATE OR DELETE ON notification_subscription FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_workspace_billing_audit ON workspace_billing;
CREATE TRIGGER trg_workspace_billing_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_billing FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_workspace_custom_branding_audit ON workspace_custom_branding;
CREATE TRIGGER trg_workspace_custom_branding_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_custom_branding FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_workspace_domain_audit ON workspace_domain;
CREATE TRIGGER trg_workspace_domain_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_domain FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_workspace_integration_audit ON workspace_integration;
CREATE TRIGGER trg_workspace_integration_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_integration FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_workspace_invitation_audit ON workspace_invitation;
CREATE TRIGGER trg_workspace_invitation_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_invitation FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_workspace_member_audit ON workspace_member;
CREATE TRIGGER trg_workspace_member_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_member FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_workspace_pending_approval_audit ON workspace_pending_approval;
CREATE TRIGGER trg_workspace_pending_approval_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_pending_approval FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_workspace_settings_audit ON workspace_settings;
CREATE TRIGGER trg_workspace_settings_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_settings FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
DROP TRIGGER IF EXISTS trg_workspace_webhook_audit ON workspace_webhook;
CREATE TRIGGER trg_workspace_webhook_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_webhook FOR EACH ROW EXECUTE FUNCTION record_workspace_audit();
-- ============================================================
-- 6. User security log
-- Password changes, logins, session revocations.
-- ============================================================
CREATE OR REPLACE FUNCTION record_user_security_event()
RETURNS TRIGGER AS $$
DECLARE
actor UUID := app_current_user_id();
BEGIN
IF TG_OP = 'UPDATE' THEN
IF NEW.password_hash IS DISTINCT FROM OLD.password_hash THEN
INSERT INTO user_security_log (user_id, event_type, description, created_at)
VALUES (NEW.user_id, 'password_changed', 'Password updated', NOW());
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_user_password_security ON user_password;
CREATE TRIGGER trg_user_password_security
AFTER UPDATE ON user_password
FOR EACH ROW EXECUTE FUNCTION record_user_security_event();
CREATE OR REPLACE FUNCTION record_user_session_event()
RETURNS TRIGGER AS $$
DECLARE
actor UUID := app_current_user_id();
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO user_security_log (user_id, event_type, description, ip_address, user_agent, created_at)
VALUES (NEW.user_id, 'login', 'User logged in', NEW.ip_address, NEW.user_agent, NOW());
ELSIF TG_OP = 'UPDATE' AND NEW.revoked_at IS NOT NULL AND OLD.revoked_at IS NULL THEN
INSERT INTO user_security_log (user_id, event_type, description, created_at)
VALUES (NEW.user_id, 'session_revoked', 'Session revoked', NOW());
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_user_session_event ON user_session;
CREATE TRIGGER trg_user_session_event
AFTER INSERT OR UPDATE ON user_session
FOR EACH ROW EXECUTE FUNCTION record_user_session_event();
-- ============================================================
-- 7. Stats auto-maintenance
-- issue_stats.comments_count — incremented on comment insert/delete.
-- channel_stats.messages_count — incremented on message insert.
-- Stats rows MUST be pre-created (no auto-INSERT with zero counts).
-- ============================================================
-- 7a. Issue comment stats (UPDATE only, no INSERT)
CREATE OR REPLACE FUNCTION update_issue_stats()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE issue_stats
SET comments_count = comments_count + 1,
updated_at = NOW()
WHERE issue_id = NEW.issue_id;
IF NOT FOUND THEN
RAISE WARNING 'issue_stats row missing for issue_id %. Insert skipped; seed stats row first.', NEW.issue_id;
END IF;
ELSIF TG_OP = 'DELETE' THEN
UPDATE issue_stats
SET comments_count = GREATEST(comments_count - 1, 0),
updated_at = NOW()
WHERE issue_id = OLD.issue_id;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_issue_comment_stats ON issue_comment;
CREATE TRIGGER trg_issue_comment_stats
AFTER INSERT OR DELETE ON issue_comment
FOR EACH ROW EXECUTE FUNCTION update_issue_stats();
-- 7b. Channel message stats (UPDATE only, no INSERT)
CREATE OR REPLACE FUNCTION update_channel_stats()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE channel_stats
SET messages_count = messages_count + 1,
last_activity_at = NOW(),
updated_at = NOW()
WHERE channel_id = NEW.channel_id;
IF NOT FOUND THEN
RAISE WARNING 'channel_stats row missing for channel_id %. Insert skipped; seed stats row first.', NEW.channel_id;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_channel_message_stats ON message;
CREATE TRIGGER trg_channel_message_stats
AFTER INSERT ON message
FOR EACH ROW EXECUTE FUNCTION update_channel_stats();
+18
View File
@@ -0,0 +1,18 @@
-- ============================================================
-- Migration: 003_user_2fa.sql
-- Table: user_2fa — TOTP two-factor authentication
-- ============================================================
BEGIN;
CREATE TABLE IF NOT EXISTS user_2fa (
user_id UUID PRIMARY KEY REFERENCES "user"(id) ON DELETE CASCADE,
secret TEXT NULL,
backup_codes TEXT NOT NULL,
enabled BOOLEAN NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
COMMIT;
+8
View File
@@ -0,0 +1,8 @@
-- Auth identity uniqueness and lookup hardening.
CREATE UNIQUE INDEX IF NOT EXISTS uq_user_username_active_ci
ON "user" (lower(username))
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_user_mail_verified_email_ci
ON user_mail (lower(email))
WHERE is_verified = true;
+124
View File
@@ -0,0 +1,124 @@
-- 005: Issue workspace-level upgrade + slug removal
-- 1. Drop slug from workspace
-- 2. Lift issue from repo-level to workspace-level (repo_id -> workspace_id)
-- 3. Add issue_repo_relation for multi-repo association
-- 4. Add milestone_id to issue
-- 5. Add missing unique constraints
ALTER TABLE workspace DROP COLUMN IF EXISTS slug;
CREATE UNIQUE INDEX IF NOT EXISTS uq_workspace_name ON workspace (lower(name)) WHERE deleted_at IS NULL;
DROP INDEX IF EXISTS idx_issue_repo_id;
DROP INDEX IF EXISTS idx_issue_repo_created;
ALTER TABLE issue DROP CONSTRAINT IF EXISTS issue_repo_id_fkey;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'issue'
AND column_name = 'repo_id'
)
AND NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'issue'
AND column_name = 'workspace_id'
) THEN
ALTER TABLE issue RENAME COLUMN repo_id TO workspace_id;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'issue_workspace_id_fkey'
AND conrelid = 'issue'::regclass
) THEN
ALTER TABLE issue
ADD CONSTRAINT issue_workspace_id_fkey
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_issue_workspace_id ON issue (workspace_id);
CREATE INDEX IF NOT EXISTS idx_issue_ws_created ON issue (workspace_id, created_at DESC);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'uq_issue_workspace_number'
AND conrelid = 'issue'::regclass
) THEN
ALTER TABLE issue
ADD CONSTRAINT uq_issue_workspace_number UNIQUE (workspace_id, number);
END IF;
END $$;
CREATE TABLE IF NOT EXISTS issue_repo_relation (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE,
repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
relation_type TEXT NOT NULL,
created_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_issue_repo_relation UNIQUE (issue_id, repo_id)
);
CREATE INDEX IF NOT EXISTS idx_issue_repo_relation_issue_id
ON issue_repo_relation (issue_id);
CREATE INDEX IF NOT EXISTS idx_issue_repo_relation_repo_id
ON issue_repo_relation (repo_id);
ALTER TABLE issue
ADD COLUMN IF NOT EXISTS milestone_id UUID NULL REFERENCES issue_milestone(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_issue_milestone_id ON issue (milestone_id);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'uq_issue_label_relation'
AND conrelid = 'issue_label_relation'::regclass
) THEN
ALTER TABLE issue_label_relation
ADD CONSTRAINT uq_issue_label_relation UNIQUE (issue_id, label_id);
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uq_repo_workspace_name
ON repo (workspace_id, lower(name)) WHERE deleted_at IS NULL;
-- ─── 7. pull_request: unique number per repo ───────────────────────────
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_pull_request_repo_number' AND conrelid = 'pull_request'::regclass
) THEN
ALTER TABLE pull_request
ADD CONSTRAINT uq_pull_request_repo_number UNIQUE (repo_id, number);
END IF;
END $$;
-- ─── 8. pr_label_relation: unique constraint ────────────────────────────
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_pr_label_relation' AND conrelid = 'pr_label_relation'::regclass
) THEN
ALTER TABLE pr_label_relation
ADD CONSTRAINT uq_pr_label_relation UNIQUE (pull_request_id, label_id);
END IF;
END $$;
+76
View File
@@ -0,0 +1,76 @@
-- 006: PR Reviews, Branch Protection, Fork enhancements
CREATE TABLE IF NOT EXISTS pr_review (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
state TEXT NOT NULL,
body TEXT NULL,
commit_sha TEXT NULL,
submitted_at TIMESTAMPTZ NULL,
dismissed_at TIMESTAMPTZ NULL,
dismissed_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE,
dismiss_reason TEXT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pr_review_pull_request_id ON pr_review (pull_request_id);
CREATE INDEX IF NOT EXISTS idx_pr_review_author_id ON pr_review (author_id);
CREATE TABLE IF NOT EXISTS pr_review_comment (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
review_id UUID NOT NULL REFERENCES pr_review(id) ON DELETE CASCADE,
pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
body TEXT NOT NULL,
path TEXT NOT NULL,
line INTEGER NULL,
original_line INTEGER NULL,
start_line INTEGER NULL,
original_start_line INTEGER NULL,
diff_hunk TEXT NULL,
in_reply_to_id UUID NULL REFERENCES pr_review_comment(id) ON DELETE CASCADE,
edited_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pr_review_comment_review_id ON pr_review_comment (review_id);
CREATE INDEX IF NOT EXISTS idx_pr_review_comment_pull_request_id ON pr_review_comment (pull_request_id);
CREATE INDEX IF NOT EXISTS idx_pr_review_comment_author_id ON pr_review_comment (author_id);
CREATE INDEX IF NOT EXISTS idx_pr_review_comment_path ON pr_review_comment (path);
CREATE TABLE IF NOT EXISTS branch_protection_rule (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
pattern TEXT NOT NULL,
require_approvals INTEGER NOT NULL DEFAULT 0,
require_status_checks BOOLEAN NOT NULL DEFAULT false,
required_status_checks TEXT[] NOT NULL DEFAULT '{}',
require_linear_history BOOLEAN NOT NULL DEFAULT false,
allow_force_pushes BOOLEAN NOT NULL DEFAULT false,
allow_deletions BOOLEAN NOT NULL DEFAULT false,
require_signed_commits BOOLEAN NOT NULL DEFAULT false,
require_code_owner_review BOOLEAN NOT NULL DEFAULT false,
dismiss_stale_reviews BOOLEAN NOT NULL DEFAULT false,
restrict_pushes BOOLEAN NOT NULL DEFAULT false,
push_allowances UUID[] NOT NULL DEFAULT '{}',
restrict_review_dismissal BOOLEAN NOT NULL DEFAULT false,
dismissal_allowances UUID[] NOT NULL DEFAULT '{}',
require_conversation_resolution BOOLEAN NOT NULL DEFAULT false,
created_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_branch_protection_rule UNIQUE (repo_id, pattern)
);
CREATE INDEX IF NOT EXISTS idx_branch_protection_rule_repo_id ON branch_protection_rule (repo_id);
CREATE TABLE IF NOT EXISTS repo_fork (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
fork_repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
forked_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_repo_fork UNIQUE (parent_repo_id, fork_repo_id)
);
CREATE INDEX IF NOT EXISTS idx_repo_fork_parent ON repo_fork (parent_repo_id);
CREATE INDEX IF NOT EXISTS idx_repo_fork_fork ON repo_fork (fork_repo_id);
+16
View File
@@ -0,0 +1,16 @@
-- 007: Issue reactions
-- ─── Issue Reactions ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS issue_reaction (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
content TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id UUID NULL,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_issue_reaction UNIQUE (issue_id, user_id, content, target_type)
);
CREATE INDEX IF NOT EXISTS idx_issue_reaction_issue_id ON issue_reaction (issue_id);
CREATE INDEX IF NOT EXISTS idx_issue_reaction_user_id ON issue_reaction (user_id);
CREATE INDEX IF NOT EXISTS idx_issue_reaction_target ON issue_reaction (target_type, target_id);
+34
View File
@@ -0,0 +1,34 @@
-- 008: Wiki System
CREATE TABLE IF NOT EXISTS wiki_page (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
last_editor_id UUID REFERENCES "user"(id) ON DELETE SET NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_wiki_page_repo_slug UNIQUE (repo_id, slug)
);
CREATE TABLE IF NOT EXISTS wiki_page_revision (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page_id UUID NOT NULL REFERENCES wiki_page(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
editor_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
commit_message TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_wiki_revision_page_version UNIQUE (page_id, version)
);
CREATE INDEX idx_wiki_page_repo_id ON wiki_page(repo_id);
CREATE INDEX idx_wiki_page_slug ON wiki_page(slug);
CREATE INDEX idx_wiki_page_deleted_at ON wiki_page(deleted_at) WHERE deleted_at IS NULL;
CREATE INDEX idx_wiki_revision_page_id ON wiki_page_revision(page_id);
CREATE INDEX idx_wiki_revision_version ON wiki_page_revision(version);
+320
View File
@@ -0,0 +1,320 @@
-- 009: IM Features — Discord/Slack-class messaging support
--
-- New tables:
-- user_presence, user_activity,
-- channel_category, channel_permission_overwrite, im_integration,
-- message_attachment, message_embed, message_draft, message_pin,
-- message_edit_history, saved_message, thread_read_state,
-- custom_emoji
-- ============================================================
-- 1. User Presence
-- ============================================================
-- models/users/user_presence.rs → user_presence
CREATE TABLE IF NOT EXISTS user_presence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
status TEXT NOT NULL,
custom_status_text TEXT NULL,
custom_status_emoji TEXT NULL,
device_type TEXT NULL,
ip_address TEXT NULL,
last_active_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_user_presence_user_id UNIQUE (user_id)
);
CREATE INDEX IF NOT EXISTS idx_user_presence_status ON user_presence (status);
-- ============================================================
-- 2. User Activity (Rich Presence)
-- ============================================================
-- models/users/user_activity.rs → user_activity
CREATE TABLE IF NOT EXISTS user_activity (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
activity_type TEXT NOT NULL,
name TEXT NOT NULL,
details TEXT NULL,
state TEXT NULL,
application_id TEXT NULL,
assets JSONB NULL,
party_id TEXT NULL,
party_current_size INTEGER NULL,
party_max_size INTEGER NULL,
large_image_url TEXT NULL,
small_image_url TEXT NULL,
start_at TIMESTAMPTZ NULL,
end_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_activity_user_id ON user_activity (user_id);
-- ============================================================
-- 3. Channel Categories
-- ============================================================
-- models/channels/channel_categories.rs → channel_category
CREATE TABLE IF NOT EXISTS channel_category (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position INTEGER NOT NULL,
collapsed BOOLEAN NOT NULL,
created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_channel_category_workspace_id ON channel_category (workspace_id);
-- ============================================================
-- 4. ALTER channel — add category_id (after channel_category exists)
-- ============================================================
ALTER TABLE channel ADD COLUMN IF NOT EXISTS category_id UUID NULL REFERENCES channel_category(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_channel_category_id ON channel (category_id);
-- ============================================================
-- 5. Channel Permission Overwrites
-- ============================================================
-- models/channels/channel_permission_overwrites.rs → channel_permission_overwrite
CREATE TABLE IF NOT EXISTS channel_permission_overwrite (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
target_type TEXT NOT NULL,
target_id UUID NOT NULL,
allow TEXT[] NOT NULL,
deny TEXT[] NOT NULL,
created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_channel_perm_overwrite UNIQUE (channel_id, target_type, target_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_perm_overwrite_channel_id ON channel_permission_overwrite (channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_perm_overwrite_target ON channel_permission_overwrite (target_type, target_id);
-- ============================================================
-- 6. IM Integrations (External Bridge)
-- ============================================================
-- models/channels/im_integrations.rs → im_integration
CREATE TABLE IF NOT EXISTS im_integration (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
name TEXT NOT NULL,
external_workspace_id TEXT NULL,
internal_channel_id UUID NULL REFERENCES channel(id) ON DELETE SET NULL,
external_channel_id TEXT NULL,
bot_token_ciphertext TEXT NULL,
webhook_url TEXT NULL,
sync_direction TEXT NOT NULL,
user_mapping JSONB NULL,
enabled BOOLEAN NOT NULL,
installed_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
last_sync_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_im_integration_workspace_id ON im_integration (workspace_id);
CREATE INDEX IF NOT EXISTS idx_im_integration_internal_channel_id ON im_integration (internal_channel_id);
CREATE INDEX IF NOT EXISTS idx_im_integration_provider ON im_integration (provider);
-- ============================================================
-- 7. Message Attachments
-- ============================================================
-- models/channels/message_attachments.rs → message_attachment
CREATE TABLE IF NOT EXISTS message_attachment (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
url TEXT NOT NULL,
proxy_url TEXT NULL,
size_bytes BIGINT NOT NULL,
mime_type TEXT NOT NULL,
width INTEGER NULL,
height INTEGER NULL,
duration_ms BIGINT NULL,
thumbnail_url TEXT NULL,
blurhash TEXT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_message_attachment_message_id ON message_attachment (message_id);
CREATE INDEX IF NOT EXISTS idx_message_attachment_channel_id ON message_attachment (channel_id);
-- ============================================================
-- 8. Message Embeds (Rich Text / Link Previews)
-- ============================================================
-- models/channels/message_embeds.rs → message_embed
CREATE TABLE IF NOT EXISTS message_embed (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
embed_type TEXT NOT NULL,
title TEXT NULL,
description TEXT NULL,
url TEXT NULL,
author_name TEXT NULL,
author_url TEXT NULL,
author_icon_url TEXT NULL,
thumbnail_url TEXT NULL,
thumbnail_width INTEGER NULL,
thumbnail_height INTEGER NULL,
image_url TEXT NULL,
image_width INTEGER NULL,
image_height INTEGER NULL,
video_url TEXT NULL,
video_width INTEGER NULL,
video_height INTEGER NULL,
color INTEGER NULL,
fields JSONB NULL,
footer_text TEXT NULL,
footer_icon_url TEXT NULL,
provider_name TEXT NULL,
provider_url TEXT NULL,
"timestamp" TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_message_embed_message_id ON message_embed (message_id);
-- ============================================================
-- 9. Message Drafts
-- ============================================================
-- models/channels/message_drafts.rs → message_draft
CREATE TABLE IF NOT EXISTS message_draft (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
thread_id UUID NULL,
reply_to_message_id UUID NULL,
content TEXT NOT NULL,
attachments JSONB NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_message_draft_user_channel UNIQUE (user_id, channel_id)
);
CREATE INDEX IF NOT EXISTS idx_message_draft_user_id ON message_draft (user_id);
CREATE INDEX IF NOT EXISTS idx_message_draft_channel_id ON message_draft (channel_id);
-- ============================================================
-- 10. Custom Emojis
-- ============================================================
-- models/channels/custom_emojis.rs → custom_emoji
CREATE TABLE IF NOT EXISTS custom_emoji (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
name TEXT NOT NULL,
url TEXT NOT NULL,
animated BOOLEAN NOT NULL,
managed BOOLEAN NOT NULL,
created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_custom_emoji_workspace_name UNIQUE (workspace_id, name)
);
CREATE INDEX IF NOT EXISTS idx_custom_emoji_workspace_id ON custom_emoji (workspace_id);
-- ============================================================
-- 11. Message Pins
-- ============================================================
-- models/channels/message_pins.rs → message_pin
CREATE TABLE IF NOT EXISTS message_pin (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
pinned_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
pinned_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_message_pin_message_id UNIQUE (message_id)
);
CREATE INDEX IF NOT EXISTS idx_message_pin_channel_id ON message_pin (channel_id);
CREATE INDEX IF NOT EXISTS idx_message_pin_message_id ON message_pin (message_id);
-- ============================================================
-- 12. Message Edit History
-- ============================================================
-- models/channels/message_edit_history.rs → message_edit_history
CREATE TABLE IF NOT EXISTS message_edit_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
previous_body TEXT NOT NULL,
edited_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
edited_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_message_edit_history_message_id ON message_edit_history (message_id);
CREATE INDEX IF NOT EXISTS idx_message_edit_history_channel_id ON message_edit_history (channel_id);
-- ============================================================
-- 13. Saved Messages
-- ============================================================
-- models/channels/saved_messages.rs → saved_message
CREATE TABLE IF NOT EXISTS saved_message (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
note TEXT NULL,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_saved_message_user_message UNIQUE (user_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_saved_message_user_id ON saved_message (user_id);
CREATE INDEX IF NOT EXISTS idx_saved_message_message_id ON saved_message (message_id);
-- ============================================================
-- 14. Thread Read States
-- ============================================================
-- models/channels/thread_read_states.rs → thread_read_state
CREATE TABLE IF NOT EXISTS thread_read_state (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
thread_id UUID NOT NULL,
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
last_read_message_id UUID NULL,
last_read_at TIMESTAMPTZ NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_thread_read_state_user_thread UNIQUE (user_id, thread_id)
);
CREATE INDEX IF NOT EXISTS idx_thread_read_state_user_id ON thread_read_state (user_id);
CREATE INDEX IF NOT EXISTS idx_thread_read_state_thread_id ON thread_read_state (thread_id);
CREATE INDEX IF NOT EXISTS idx_thread_read_state_channel_id ON thread_read_state (channel_id);
-- ============================================================
-- 15. Triggers — auto-refresh updated_at
-- ============================================================
DROP TRIGGER IF EXISTS trg_user_presence_updated_at ON user_presence;
CREATE TRIGGER trg_user_presence_updated_at BEFORE UPDATE ON user_presence FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_user_activity_updated_at ON user_activity;
CREATE TRIGGER trg_user_activity_updated_at BEFORE UPDATE ON user_activity FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_channel_category_updated_at ON channel_category;
CREATE TRIGGER trg_channel_category_updated_at BEFORE UPDATE ON channel_category FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_channel_permission_overwrite_updated_at ON channel_permission_overwrite;
CREATE TRIGGER trg_channel_permission_overwrite_updated_at BEFORE UPDATE ON channel_permission_overwrite FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_im_integration_updated_at ON im_integration;
CREATE TRIGGER trg_im_integration_updated_at BEFORE UPDATE ON im_integration FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_message_draft_updated_at ON message_draft;
CREATE TRIGGER trg_message_draft_updated_at BEFORE UPDATE ON message_draft FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_custom_emoji_updated_at ON custom_emoji;
CREATE TRIGGER trg_custom_emoji_updated_at BEFORE UPDATE ON custom_emoji FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_thread_read_state_updated_at ON thread_read_state;
CREATE TRIGGER trg_thread_read_state_updated_at BEFORE UPDATE ON thread_read_state FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+187
View File
@@ -0,0 +1,187 @@
-- 010: Channel Kinds — text, voice, stage, forum, announcement
--
-- ALTER:
-- channel — add channel_kind + voice/forum/stage fields
-- message_thread — add forum post fields (title, tags, pinned, locked)
--
-- New tables:
-- forum_tag, voice_participant, stage,
-- message_poll, message_poll_option, message_poll_vote
-- ============================================================
-- 1. ALTER channel — add channel_kind + voice/forum/stage fields
-- ============================================================
ALTER TABLE channel ADD COLUMN IF NOT EXISTS channel_kind TEXT NOT NULL DEFAULT 'text';
ALTER TABLE channel ADD COLUMN IF NOT EXISTS position INTEGER NULL;
ALTER TABLE channel ADD COLUMN IF NOT EXISTS nsfw BOOLEAN NOT NULL DEFAULT FALSE;
-- Voice / Stage specific
ALTER TABLE channel ADD COLUMN IF NOT EXISTS bitrate INTEGER NULL;
ALTER TABLE channel ADD COLUMN IF NOT EXISTS user_limit INTEGER NULL;
ALTER TABLE channel ADD COLUMN IF NOT EXISTS rtc_region TEXT NULL;
-- Forum specific
ALTER TABLE channel ADD COLUMN IF NOT EXISTS default_auto_archive_duration INTEGER NULL;
ALTER TABLE channel ADD COLUMN IF NOT EXISTS default_reaction_emoji TEXT NULL;
ALTER TABLE channel ADD COLUMN IF NOT EXISTS default_sort_order TEXT NULL;
ALTER TABLE channel ADD COLUMN IF NOT EXISTS default_forum_layout TEXT NULL;
ALTER TABLE channel ADD COLUMN IF NOT EXISTS require_tag BOOLEAN NULL;
ALTER TABLE channel ADD COLUMN IF NOT EXISTS available_tags JSONB NULL;
ALTER TABLE channel ADD COLUMN IF NOT EXISTS default_thread_rate_limit INTEGER NULL;
-- General
ALTER TABLE channel ADD COLUMN IF NOT EXISTS rate_limit_per_user INTEGER NULL;
ALTER TABLE channel ADD COLUMN IF NOT EXISTS parent_channel_id UUID NULL REFERENCES channel(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_channel_channel_kind ON channel (channel_kind);
CREATE INDEX IF NOT EXISTS idx_channel_parent_channel_id ON channel (parent_channel_id);
-- ============================================================
-- 2. ALTER message_thread — add forum post fields
-- ============================================================
ALTER TABLE message_thread ADD COLUMN IF NOT EXISTS title TEXT NULL;
ALTER TABLE message_thread ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE message_thread ADD COLUMN IF NOT EXISTS pinned BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE message_thread ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE message_thread ADD COLUMN IF NOT EXISTS rate_limit_per_user INTEGER NULL;
ALTER TABLE message_thread ADD COLUMN IF NOT EXISTS auto_archive_at TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_message_thread_pinned ON message_thread (pinned) WHERE pinned;
-- ============================================================
-- 3. Forum Tags
-- ============================================================
-- models/channels/forum_tags.rs → forum_tag
CREATE TABLE IF NOT EXISTS forum_tag (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
name TEXT NOT NULL,
emoji_id TEXT NULL,
emoji_name TEXT NULL,
moderated BOOLEAN NOT NULL,
position INTEGER NOT NULL,
created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_forum_tag_channel_name UNIQUE (channel_id, name)
);
CREATE INDEX IF NOT EXISTS idx_forum_tag_channel_id ON forum_tag (channel_id);
-- ============================================================
-- 4. Voice Participants
-- ============================================================
-- models/channels/voice_participants.rs → voice_participant
CREATE TABLE IF NOT EXISTS voice_participant (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
session_id TEXT NULL,
deafened BOOLEAN NOT NULL,
muted BOOLEAN NOT NULL,
self_deafened BOOLEAN NOT NULL,
self_muted BOOLEAN NOT NULL,
self_video BOOLEAN NOT NULL,
streaming BOOLEAN NOT NULL,
speaking BOOLEAN NOT NULL,
joined_at TIMESTAMPTZ NOT NULL,
left_at TIMESTAMPTZ NULL
);
CREATE INDEX IF NOT EXISTS idx_voice_participant_channel_id ON voice_participant (channel_id);
CREATE INDEX IF NOT EXISTS idx_voice_participant_user_id ON voice_participant (user_id);
CREATE INDEX IF NOT EXISTS idx_voice_participant_channel_user ON voice_participant (channel_id, user_id);
-- ============================================================
-- 5. Stages
-- ============================================================
-- models/channels/stages.rs → stage
CREATE TABLE IF NOT EXISTS stage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
topic TEXT NOT NULL,
privacy_level TEXT NOT NULL,
discoverable BOOLEAN NOT NULL,
started_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_stage_active_channel UNIQUE (channel_id, ended_at)
);
CREATE INDEX IF NOT EXISTS idx_stage_channel_id ON stage (channel_id);
CREATE INDEX IF NOT EXISTS idx_stage_channel_active ON stage (channel_id) WHERE ended_at IS NULL;
-- ============================================================
-- 6. Message Polls
-- ============================================================
-- models/channels/message_polls.rs → message_poll
CREATE TABLE IF NOT EXISTS message_poll (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
question TEXT NOT NULL,
description TEXT NULL,
layout TEXT NOT NULL,
allow_multiselect BOOLEAN NOT NULL,
duration_hours INTEGER NULL,
ends_at TIMESTAMPTZ NULL,
total_votes BIGINT NOT NULL,
metadata JSONB NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_message_poll_message_id UNIQUE (message_id)
);
CREATE INDEX IF NOT EXISTS idx_message_poll_channel_id ON message_poll (channel_id);
CREATE INDEX IF NOT EXISTS idx_message_poll_ends_at ON message_poll (ends_at) WHERE ends_at IS NOT NULL;
-- ============================================================
-- 7. Message Poll Options
-- ============================================================
-- models/channels/message_poll_options.rs → message_poll_option
CREATE TABLE IF NOT EXISTS message_poll_option (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
poll_id UUID NOT NULL REFERENCES message_poll(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
text TEXT NOT NULL,
emoji_id TEXT NULL,
emoji_name TEXT NULL,
vote_count BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_message_poll_option_poll_id ON message_poll_option (poll_id);
-- ============================================================
-- 8. Message Poll Votes
-- ============================================================
-- models/channels/message_poll_votes.rs → message_poll_vote
CREATE TABLE IF NOT EXISTS message_poll_vote (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
poll_id UUID NOT NULL REFERENCES message_poll(id) ON DELETE CASCADE,
option_id UUID NOT NULL REFERENCES message_poll_option(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
voted_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_message_poll_vote_user_option UNIQUE (poll_id, option_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_message_poll_vote_poll_id ON message_poll_vote (poll_id);
CREATE INDEX IF NOT EXISTS idx_message_poll_vote_user_id ON message_poll_vote (user_id);
CREATE INDEX IF NOT EXISTS idx_message_poll_vote_option_id ON message_poll_vote (option_id);
-- ============================================================
-- 9. Triggers — auto-refresh updated_at
-- ============================================================
DROP TRIGGER IF EXISTS trg_forum_tag_updated_at ON forum_tag;
CREATE TRIGGER trg_forum_tag_updated_at BEFORE UPDATE ON forum_tag FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_stage_updated_at ON stage;
CREATE TRIGGER trg_stage_updated_at BEFORE UPDATE ON stage FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_message_poll_updated_at ON message_poll;
CREATE TRIGGER trg_message_poll_updated_at BEFORE UPDATE ON message_poll FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+145
View File
@@ -0,0 +1,145 @@
-- 011: Announcement Channel — articles, comments, reactions, follows, cross-posts
--
-- New tables:
-- article, article_comment, article_reaction,
-- channel_follow, article_cross_post
-- ============================================================
-- 1. Articles
-- ============================================================
-- models/channels/articles.rs → article
CREATE TABLE IF NOT EXISTS article (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
title TEXT NOT NULL,
slug TEXT NOT NULL,
summary TEXT NULL,
body TEXT NOT NULL,
cover_image_url TEXT NULL,
status TEXT NOT NULL,
visibility TEXT NOT NULL,
tags TEXT[] NOT NULL,
published_at TIMESTAMPTZ NULL,
published_by UUID NULL REFERENCES "user"(id) ON DELETE SET NULL,
scheduled_at TIMESTAMPTZ NULL,
unpublished_at TIMESTAMPTZ NULL,
views_count BIGINT NOT NULL DEFAULT 0,
comments_count BIGINT NOT NULL DEFAULT 0,
reactions_count BIGINT NOT NULL DEFAULT 0,
cross_posted BOOLEAN NOT NULL DEFAULT FALSE,
cross_posted_from UUID NULL REFERENCES article(id) ON DELETE SET NULL,
metadata JSONB NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_article_channel_slug UNIQUE (channel_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_article_channel_id ON article (channel_id);
CREATE INDEX IF NOT EXISTS idx_article_author_id ON article (author_id);
CREATE INDEX IF NOT EXISTS idx_article_status ON article (status);
CREATE INDEX IF NOT EXISTS idx_article_published_at ON article (published_at DESC) WHERE published_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_article_cross_posted_from ON article (cross_posted_from) WHERE cross_posted_from IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_article_deleted ON article (deleted_at) WHERE deleted_at IS NOT NULL;
-- ============================================================
-- 2. Article Comments
-- ============================================================
-- models/channels/article_comments.rs → article_comment
CREATE TABLE IF NOT EXISTS article_comment (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES article(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
parent_comment_id UUID NULL REFERENCES article_comment(id) ON DELETE CASCADE,
body TEXT NOT NULL,
edited_at TIMESTAMPTZ NULL,
deleted_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_article_comment_article_id ON article_comment (article_id);
CREATE INDEX IF NOT EXISTS idx_article_comment_author_id ON article_comment (author_id);
CREATE INDEX IF NOT EXISTS idx_article_comment_parent ON article_comment (parent_comment_id) WHERE parent_comment_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_article_comment_deleted ON article_comment (deleted_at) WHERE deleted_at IS NOT NULL;
-- ============================================================
-- 3. Article Reactions
-- ============================================================
-- models/channels/article_reactions.rs → article_reaction
CREATE TABLE IF NOT EXISTS article_reaction (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES article(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_article_reaction UNIQUE (article_id, user_id, content)
);
CREATE INDEX IF NOT EXISTS idx_article_reaction_article_id ON article_reaction (article_id);
CREATE INDEX IF NOT EXISTS idx_article_reaction_user_id ON article_reaction (user_id);
-- ============================================================
-- 4. Channel Follows
-- ============================================================
-- models/channels/channel_follows.rs → channel_follow
CREATE TABLE IF NOT EXISTS channel_follow (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
target_workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
target_channel_id UUID NULL REFERENCES channel(id) ON DELETE SET NULL,
webhook_url TEXT NULL,
webhook_secret_ciphertext TEXT NULL,
enabled BOOLEAN NOT NULL,
followed_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
unfollowed_at TIMESTAMPTZ NULL,
last_delivery_at TIMESTAMPTZ NULL,
last_delivery_status TEXT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_channel_follow_source_target UNIQUE (source_channel_id, target_workspace_id, target_channel_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_follow_source ON channel_follow (source_channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_follow_target_ws ON channel_follow (target_workspace_id);
CREATE INDEX IF NOT EXISTS idx_channel_follow_target_channel ON channel_follow (target_channel_id) WHERE target_channel_id IS NOT NULL;
-- ============================================================
-- 5. Article Cross-Posts
-- ============================================================
-- models/channels/article_cross_posts.rs → article_cross_post
CREATE TABLE IF NOT EXISTS article_cross_post (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES article(id) ON DELETE CASCADE,
follow_id UUID NOT NULL REFERENCES channel_follow(id) ON DELETE CASCADE,
target_workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
target_channel_id UUID NULL REFERENCES channel(id) ON DELETE SET NULL,
status TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NULL,
sent_at TIMESTAMPTZ NULL,
delivered_at TIMESTAMPTZ NULL,
failed_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_article_cross_post_article_id ON article_cross_post (article_id);
CREATE INDEX IF NOT EXISTS idx_article_cross_post_follow_id ON article_cross_post (follow_id);
CREATE INDEX IF NOT EXISTS idx_article_cross_post_status ON article_cross_post (status);
CREATE INDEX IF NOT EXISTS idx_article_cross_post_target_ws ON article_cross_post (target_workspace_id);
-- ============================================================
-- 6. Triggers — auto-refresh updated_at
-- ============================================================
DROP TRIGGER IF EXISTS trg_article_updated_at ON article;
CREATE TRIGGER trg_article_updated_at BEFORE UPDATE ON article FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_article_comment_updated_at ON article_comment;
CREATE TRIGGER trg_article_comment_updated_at BEFORE UPDATE ON article_comment FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_channel_follow_updated_at ON channel_follow;
CREATE TRIGGER trg_channel_follow_updated_at BEFORE UPDATE ON channel_follow FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+13
View File
@@ -0,0 +1,13 @@
ALTER TABLE message ADD COLUMN IF NOT EXISTS seq BIGINT NOT NULL DEFAULT 0;
WITH ranked AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY channel_id ORDER BY created_at ASC, id ASC) AS rn
FROM message
WHERE seq = 0
)
UPDATE message m
SET seq = ranked.rn
FROM ranked
WHERE m.id = ranked.id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_message_channel_seq ON message (channel_id, seq);
+28
View File
@@ -0,0 +1,28 @@
use crate::models::common::{AgentType, Status, Visibility};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Agent {
pub id: Uuid,
pub owner_id: Uuid,
pub workspace_id: Option<Uuid>,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub avatar_url: Option<String>,
pub agent_type: AgentType,
pub status: Status,
pub visibility: Visibility,
pub default_model_id: Option<Uuid>,
pub current_version_id: Option<Uuid>,
pub system_prompt: Option<String>,
pub tools: Vec<String>,
pub tags: Vec<String>,
pub enabled: bool,
pub last_run_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
@@ -0,0 +1,19 @@
use crate::models::common::EventType;
use crate::models::json_types::{AgentEventFilters, TypedJson};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct AgentEventSubscription {
pub id: Uuid,
pub agent_id: Uuid,
pub workspace_id: Option<Uuid>,
pub repo_id: Option<Uuid>,
pub event_type: EventType,
pub filters: Option<TypedJson<AgentEventFilters>>,
pub enabled: bool,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+24
View File
@@ -0,0 +1,24 @@
use crate::models::common::{JsonValue, Status, StepType};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct AgentExecutionStep {
pub id: Uuid,
pub execution_id: Uuid,
pub step_index: i32,
pub step_type: StepType,
pub name: String,
pub status: Status,
pub model_id: Option<Uuid>,
pub tool_name: Option<String>,
pub input: Option<JsonValue>,
pub output: Option<JsonValue>,
pub error_message: Option<String>,
pub token_input_count: Option<i32>,
pub token_output_count: Option<i32>,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
+25
View File
@@ -0,0 +1,25 @@
use crate::models::common::{JsonValue, Status, TriggerType};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct AgentExecution {
pub id: Uuid,
pub agent_id: Uuid,
pub agent_version_id: Option<Uuid>,
pub workspace_id: Option<Uuid>,
pub repo_id: Option<Uuid>,
pub issue_id: Option<Uuid>,
pub pull_request_id: Option<Uuid>,
pub triggered_by: Option<Uuid>,
pub trigger_type: TriggerType,
pub status: Status,
pub input: Option<JsonValue>,
pub output: Option<JsonValue>,
pub error_message: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+18
View File
@@ -0,0 +1,18 @@
use crate::models::common::{FeedbackType, JsonValue};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct AgentFeedback {
pub id: Uuid,
pub agent_id: Uuid,
pub execution_id: Option<Uuid>,
pub user_id: Uuid,
pub rating: i32,
pub feedback_type: FeedbackType,
pub comment: Option<String>,
pub metadata: Option<JsonValue>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+22
View File
@@ -0,0 +1,22 @@
use crate::models::json_types::{AgentSchedulePayload, TypedJson};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct AgentSchedule {
pub id: Uuid,
pub agent_id: Uuid,
pub workspace_id: Option<Uuid>,
pub repo_id: Option<Uuid>,
pub name: String,
pub cron_expr: String,
pub timezone: String,
pub payload: Option<TypedJson<AgentSchedulePayload>>,
pub enabled: bool,
pub last_run_at: Option<DateTime<Utc>>,
pub next_run_at: Option<DateTime<Utc>>,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+24
View File
@@ -0,0 +1,24 @@
use crate::models::json_types::{AgentVersionConfig, TypedJson};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct AgentVersion {
pub id: Uuid,
pub agent_id: Uuid,
pub version: String,
pub name: Option<String>,
pub description: Option<String>,
pub model_id: Option<Uuid>,
pub system_prompt: String,
pub instructions: Option<String>,
pub tools: Vec<String>,
pub config: Option<TypedJson<AgentVersionConfig>>,
pub changelog: Option<String>,
pub stable: bool,
pub published_by: Uuid,
pub published_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+18
View File
@@ -0,0 +1,18 @@
use crate::models::common::{Permission, Role};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct AgentWorkspaceBinding {
pub id: Uuid,
pub agent_id: Uuid,
pub workspace_id: Uuid,
pub repo_id: Option<Uuid>,
pub role: Role,
pub permissions: Vec<Permission>,
pub enabled: bool,
pub bound_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+17
View File
@@ -0,0 +1,17 @@
pub mod agent;
pub mod agent_event_subscriptions;
pub mod agent_execution_steps;
pub mod agent_executions;
pub mod agent_feedback;
pub mod agent_schedules;
pub mod agent_versions;
pub mod agent_workspace_bindings;
pub use agent::Agent;
pub use agent_event_subscriptions::AgentEventSubscription;
pub use agent_execution_steps::AgentExecutionStep;
pub use agent_executions::AgentExecution;
pub use agent_feedback::AgentFeedback;
pub use agent_schedules::AgentSchedule;
pub use agent_versions::AgentVersion;
pub use agent_workspace_bindings::AgentWorkspaceBinding;
+15
View File
@@ -0,0 +1,15 @@
use crate::models::json_types::{AiModelCapabilityConfig, TypedJson};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct AiModelCapability {
pub id: Uuid,
pub ai_model_id: Uuid,
pub capability: String,
pub supported: bool,
pub config: Option<TypedJson<AiModelCapabilityConfig>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+16
View File
@@ -0,0 +1,16 @@
use crate::models::common::Status;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct AiModelHealth {
pub id: Uuid,
pub ai_model_id: Uuid,
pub status: Status,
pub latency_ms: Option<i32>,
pub error_rate: Option<String>,
pub last_error: Option<String>,
pub checked_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
+19
View File
@@ -0,0 +1,19 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct AiModelVersion {
pub id: Uuid,
pub ai_model_id: Uuid,
pub version: String,
pub provider_model_id: String,
pub context_window: Option<i32>,
pub max_output_tokens: Option<i32>,
pub changelog: Option<String>,
pub stable: bool,
pub deprecated: bool,
pub released_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+30
View File
@@ -0,0 +1,30 @@
use crate::models::common::{AiFeature, AiModelType, Modality, PricingUnit, Provider};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct AiModel {
pub id: Uuid,
pub provider: Provider,
pub model_id: String,
pub name: String,
pub description: Option<String>,
pub model_type: AiModelType,
pub family: Option<String>,
pub version: Option<String>,
pub context_window: Option<i32>,
pub max_output_tokens: Option<i32>,
pub input_modalities: Vec<Modality>,
pub output_modalities: Vec<Modality>,
pub supported_features: Vec<AiFeature>,
pub pricing_unit: Option<PricingUnit>,
pub input_price_per_unit: Option<String>,
pub output_price_per_unit: Option<String>,
pub enabled: bool,
pub deprecated: bool,
pub released_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
+9
View File
@@ -0,0 +1,9 @@
pub mod ai_model_capabilities;
pub mod ai_model_health;
pub mod ai_model_versions;
pub mod ai_models;
pub use ai_model_capabilities::AiModelCapability;
pub use ai_model_health::AiModelHealth;
pub use ai_model_versions::AiModelVersion;
pub use ai_models::AiModel;
+18
View File
@@ -0,0 +1,18 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Discussion comment on an article (similar to blog comments).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ArticleComment {
pub id: Uuid,
pub article_id: Uuid,
pub channel_id: Uuid,
pub author_id: Uuid,
pub parent_comment_id: Option<Uuid>,
pub body: String,
pub edited_at: Option<DateTime<Utc>>,
pub deleted_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+22
View File
@@ -0,0 +1,22 @@
use crate::models::common::Status;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Tracks a single cross-post delivery when an article is published
/// to a follower channel/workspace.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ArticleCrossPost {
pub id: Uuid,
pub article_id: Uuid,
pub follow_id: Uuid,
pub target_workspace_id: Uuid,
pub target_channel_id: Option<Uuid>,
pub status: Status,
pub attempts: i32,
pub last_error: Option<String>,
pub sent_at: Option<DateTime<Utc>>,
pub delivered_at: Option<DateTime<Utc>>,
pub failed_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
+14
View File
@@ -0,0 +1,14 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Reaction on an article (emoji reactions, separate from message reactions).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ArticleReaction {
pub id: Uuid,
pub article_id: Uuid,
pub channel_id: Uuid,
pub user_id: Uuid,
pub content: String,
pub created_at: DateTime<Utc>,
}
+35
View File
@@ -0,0 +1,35 @@
use crate::models::common::{ArticleStatus, JsonValue, Visibility};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Long-form article for announcement/news channels.
/// Unlike a plain Message, an article has a title, cover image,
/// publish lifecycle, and can be cross-posted to followers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Article {
pub id: Uuid,
pub channel_id: Uuid,
pub author_id: Uuid,
pub title: String,
pub slug: String,
pub summary: Option<String>,
pub body: String,
pub cover_image_url: Option<String>,
pub status: ArticleStatus,
pub visibility: Visibility,
pub tags: Vec<String>,
pub published_at: Option<DateTime<Utc>>,
pub published_by: Option<Uuid>,
pub scheduled_at: Option<DateTime<Utc>>,
pub unpublished_at: Option<DateTime<Utc>>,
pub views_count: i64,
pub comments_count: i64,
pub reactions_count: i64,
pub cross_posted: bool,
pub cross_posted_from: Option<Uuid>,
pub metadata: Option<JsonValue>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
+43
View File
@@ -0,0 +1,43 @@
use crate::models::common::{
ChannelKind, ChannelType, ForumLayout, ForumSortOrder, JsonValue, Visibility,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Channel {
pub id: Uuid,
pub workspace_id: Uuid,
pub repo_id: Option<Uuid>,
pub category_id: Option<Uuid>,
pub created_by: Uuid,
pub name: String,
pub topic: Option<String>,
pub description: Option<String>,
pub channel_type: ChannelType,
pub channel_kind: ChannelKind,
pub visibility: Visibility,
pub position: Option<i32>,
pub nsfw: bool,
pub archived: bool,
pub read_only: bool,
pub bitrate: Option<i32>,
pub user_limit: Option<i32>,
pub rtc_region: Option<String>,
pub default_auto_archive_duration: Option<i32>,
pub default_reaction_emoji: Option<String>,
pub default_sort_order: Option<ForumSortOrder>,
pub default_forum_layout: Option<ForumLayout>,
pub require_tag: Option<bool>,
pub available_tags: Option<JsonValue>,
pub default_thread_rate_limit: Option<i32>,
pub rate_limit_per_user: Option<i32>,
pub parent_channel_id: Option<Uuid>,
pub last_message_id: Option<Uuid>,
pub last_message_at: Option<DateTime<Utc>>,
pub archived_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
+15
View File
@@ -0,0 +1,15 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ChannelCategory {
pub id: Uuid,
pub workspace_id: Uuid,
pub name: String,
pub position: i32,
pub collapsed: bool,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+18
View File
@@ -0,0 +1,18 @@
use crate::models::common::{EventType, JsonValue, TargetType};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ChannelEvent {
pub id: Uuid,
pub channel_id: Uuid,
pub actor_id: Option<Uuid>,
pub event_type: EventType,
pub target_type: Option<TargetType>,
pub target_id: Option<Uuid>,
pub old_value: Option<JsonValue>,
pub new_value: Option<JsonValue>,
pub metadata: Option<JsonValue>,
pub created_at: DateTime<Utc>,
}
+23
View File
@@ -0,0 +1,23 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Follow relationship on an announcement channel.
/// Allows another workspace or channel to receive cross-posts
/// when articles are published.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ChannelFollow {
pub id: Uuid,
pub source_channel_id: Uuid,
pub target_workspace_id: Uuid,
pub target_channel_id: Option<Uuid>,
pub webhook_url: Option<String>,
pub webhook_secret_ciphertext: Option<String>,
pub enabled: bool,
pub followed_by: Uuid,
pub unfollowed_at: Option<DateTime<Utc>>,
pub last_delivery_at: Option<DateTime<Utc>>,
pub last_delivery_status: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+20
View File
@@ -0,0 +1,20 @@
use crate::models::common::Role;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ChannelInvitation {
pub id: Uuid,
pub channel_id: Uuid,
pub workspace_id: Uuid,
pub invited_user_id: Option<Uuid>,
pub email: Option<String>,
pub role: Role,
pub token_hash: String,
pub invited_by: Uuid,
pub accepted_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
+17
View File
@@ -0,0 +1,17 @@
use crate::models::common::Permission;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ChannelMemberRole {
pub id: Uuid,
pub channel_id: Uuid,
pub name: String,
pub description: Option<String>,
pub permissions: Vec<Permission>,
pub assignable: bool,
pub created_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+21
View File
@@ -0,0 +1,21 @@
use crate::models::common::{Role, Status};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ChannelMember {
pub id: Uuid,
pub channel_id: Uuid,
pub user_id: Uuid,
pub role: Role,
pub status: Status,
pub muted: bool,
pub pinned: bool,
pub last_read_message_id: Option<Uuid>,
pub last_read_at: Option<DateTime<Utc>>,
pub joined_at: Option<DateTime<Utc>>,
pub left_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -0,0 +1,17 @@
use crate::models::common::{OverwriteTarget, Permission};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ChannelPermissionOverwrite {
pub id: Uuid,
pub channel_id: Uuid,
pub target_type: OverwriteTarget,
pub target_id: Uuid,
pub allow: Vec<Permission>,
pub deny: Vec<Permission>,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+17
View File
@@ -0,0 +1,17 @@
use crate::models::common::{EventType, LinkType};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ChannelRepoLink {
pub id: Uuid,
pub channel_id: Uuid,
pub repo_id: Uuid,
pub link_type: LinkType,
pub notify_events: Vec<EventType>,
pub active: bool,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+20
View File
@@ -0,0 +1,20 @@
use crate::models::common::Scope;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ChannelSlashCommand {
pub id: Uuid,
pub channel_id: Option<Uuid>,
pub workspace_id: Uuid,
pub command: String,
pub description: Option<String>,
pub request_url: String,
pub secret_ciphertext: Option<String>,
pub scopes: Vec<Scope>,
pub enabled: bool,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+16
View File
@@ -0,0 +1,16 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ChannelStats {
pub channel_id: Uuid,
pub members_count: i64,
pub messages_count: i64,
pub threads_count: i64,
pub reactions_count: i64,
pub mentions_count: i64,
pub files_count: i64,
pub last_activity_at: Option<DateTime<Utc>>,
pub updated_at: DateTime<Utc>,
}
+20
View File
@@ -0,0 +1,20 @@
use crate::models::common::EventType;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ChannelWebhook {
pub id: Uuid,
pub channel_id: Uuid,
pub name: String,
pub url: String,
pub secret_ciphertext: Option<String>,
pub events: Vec<EventType>,
pub active: bool,
pub last_delivery_status: Option<String>,
pub last_delivery_at: Option<DateTime<Utc>>,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+16
View File
@@ -0,0 +1,16 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct CustomEmoji {
pub id: Uuid,
pub workspace_id: Uuid,
pub name: String,
pub url: String,
pub animated: bool,
pub managed: bool,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+18
View File
@@ -0,0 +1,18 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Forum channel post tags (similar to Discord forum tags).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ForumTag {
pub id: Uuid,
pub channel_id: Uuid,
pub name: String,
pub emoji_id: Option<String>,
pub emoji_name: Option<String>,
pub moderated: bool,
pub position: i32,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+24
View File
@@ -0,0 +1,24 @@
use crate::models::common::{JsonValue, Provider, SyncDirection};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct ImIntegration {
pub id: Uuid,
pub workspace_id: Uuid,
pub provider: Provider,
pub name: String,
pub external_workspace_id: Option<String>,
pub internal_channel_id: Option<Uuid>,
pub external_channel_id: Option<String>,
pub bot_token_ciphertext: Option<String>,
pub webhook_url: Option<String>,
pub sync_direction: SyncDirection,
pub user_mapping: Option<JsonValue>,
pub enabled: bool,
pub installed_by: Uuid,
pub last_sync_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+23
View File
@@ -0,0 +1,23 @@
use crate::models::common::{JsonValue, MessageType};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Message {
pub id: Uuid,
pub channel_id: Uuid,
pub author_id: Uuid,
pub thread_id: Option<Uuid>,
pub reply_to_message_id: Option<Uuid>,
pub seq: i64,
pub message_type: MessageType,
pub body: String,
pub metadata: Option<JsonValue>,
pub pinned: bool,
pub system: bool,
pub edited_at: Option<DateTime<Utc>>,
pub deleted_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+21
View File
@@ -0,0 +1,21 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageAttachment {
pub id: Uuid,
pub message_id: Uuid,
pub channel_id: Uuid,
pub filename: String,
pub url: String,
pub proxy_url: Option<String>,
pub size_bytes: i64,
pub mime_type: String,
pub width: Option<i32>,
pub height: Option<i32>,
pub duration_ms: Option<i64>,
pub thumbnail_url: Option<String>,
pub blurhash: Option<String>,
pub created_at: DateTime<Utc>,
}
+14
View File
@@ -0,0 +1,14 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageBookmark {
pub id: Uuid,
pub message_id: Uuid,
pub channel_id: Uuid,
pub user_id: Uuid,
pub note: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+17
View File
@@ -0,0 +1,17 @@
use crate::models::common::JsonValue;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageDraft {
pub id: Uuid,
pub user_id: Uuid,
pub channel_id: Uuid,
pub thread_id: Option<Uuid>,
pub reply_to_message_id: Option<Uuid>,
pub content: String,
pub attachments: Option<JsonValue>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+13
View File
@@ -0,0 +1,13 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageEditHistory {
pub id: Uuid,
pub message_id: Uuid,
pub channel_id: Uuid,
pub previous_body: String,
pub edited_by: Uuid,
pub edited_at: DateTime<Utc>,
}
+34
View File
@@ -0,0 +1,34 @@
use crate::models::common::{EmbedType, JsonValue};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageEmbed {
pub id: Uuid,
pub message_id: Uuid,
pub embed_type: EmbedType,
pub title: Option<String>,
pub description: Option<String>,
pub url: Option<String>,
pub author_name: Option<String>,
pub author_url: Option<String>,
pub author_icon_url: Option<String>,
pub thumbnail_url: Option<String>,
pub thumbnail_width: Option<i32>,
pub thumbnail_height: Option<i32>,
pub image_url: Option<String>,
pub image_width: Option<i32>,
pub image_height: Option<i32>,
pub video_url: Option<String>,
pub video_width: Option<i32>,
pub video_height: Option<i32>,
pub color: Option<i32>,
pub fields: Option<JsonValue>,
pub footer_text: Option<String>,
pub footer_icon_url: Option<String>,
pub provider_name: Option<String>,
pub provider_url: Option<String>,
pub timestamp: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
+14
View File
@@ -0,0 +1,14 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageMention {
pub id: Uuid,
pub message_id: Uuid,
pub channel_id: Uuid,
pub mentioned_user_id: Uuid,
pub mentioned_by: Uuid,
pub read_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
+12
View File
@@ -0,0 +1,12 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessagePin {
pub id: Uuid,
pub message_id: Uuid,
pub channel_id: Uuid,
pub pinned_by: Uuid,
pub pinned_at: DateTime<Utc>,
}
+16
View File
@@ -0,0 +1,16 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Poll option.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessagePollOption {
pub id: Uuid,
pub poll_id: Uuid,
pub position: i32,
pub text: String,
pub emoji_id: Option<String>,
pub emoji_name: Option<String>,
pub vote_count: i64,
pub created_at: DateTime<Utc>,
}

Some files were not shown because too many files have changed in this diff Show More