feat: init
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
/target
|
||||||
|
.idea
|
||||||
|
.codegraph
|
||||||
|
.claude
|
||||||
|
AGENT.md
|
||||||
|
CLAUDE.md
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# 已忽略包含查询文件的默认文件夹
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
Generated
+5360
File diff suppressed because it is too large
Load Diff
+52
@@ -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"
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
Vendored
+304
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+97
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+117
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ¤t)?;
|
||||||
|
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(¤t.user_id)
|
||||||
|
&& devices.get(¤t.device_id).copied() == Some(current.connection_id)
|
||||||
|
{
|
||||||
|
devices.remove(¤t.device_id);
|
||||||
|
}
|
||||||
|
self.user_devices
|
||||||
|
.remove_if(¤t.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 $$;
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>>,
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>>,
|
||||||
|
}
|
||||||
@@ -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>>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user