feat: initial commit
Axum/Postgres server with auth, migrations, and Docker setup.
This commit is contained in:
59
src/handlers/attachment.rs
Normal file
59
src/handlers/attachment.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use crate::{model::Attachment, schema::AttachmentSchema, AppState};
|
||||
use super::db_err;
|
||||
|
||||
pub async fn list_attachments(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(message_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let attachments = sqlx::query_as::<_, Attachment>(
|
||||
"SELECT * FROM attachments WHERE message_id = $1",
|
||||
)
|
||||
.bind(message_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(Json(attachments))
|
||||
}
|
||||
|
||||
pub async fn create_attachment(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<AttachmentSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let attachment = sqlx::query_as::<_, Attachment>(
|
||||
"INSERT INTO attachments (message_id, url, filename, mime_type, size_bytes)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *",
|
||||
)
|
||||
.bind(body.message_id)
|
||||
.bind(&body.url)
|
||||
.bind(&body.filename)
|
||||
.bind(&body.mime_type)
|
||||
.bind(body.size_bytes)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok((StatusCode::CREATED, Json(attachment)))
|
||||
}
|
||||
|
||||
pub async fn delete_attachment(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let res = sqlx::query("DELETE FROM attachments WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
if res.rows_affected() > 0 {
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "Attachment not found".into()))
|
||||
}
|
||||
}
|
||||
161
src/handlers/auth.rs
Normal file
161
src/handlers/auth.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
model::{AuthToken, AuthTokenKind, Claims, User, UserResponse},
|
||||
schema::{LoginSchema, RefreshSchema, RegisterSchema},
|
||||
AppState,
|
||||
};
|
||||
|
||||
use super::db_err;
|
||||
|
||||
fn make_access_token(user_id: &str, secret: &str) -> Result<String, String> {
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
exp: (Utc::now() + Duration::minutes(15)).timestamp() as usize,
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn insert_refresh_token(state: &AppState, user_id: Uuid) -> Result<String, (StatusCode, String)> {
|
||||
// ponytail: raw UUID stored in token_hash — add SHA-256 if compliance requires
|
||||
let raw = Uuid::new_v4().to_string();
|
||||
let expires = Utc::now() + Duration::days(30);
|
||||
sqlx::query(
|
||||
"INSERT INTO auth_tokens (user_id, token_hash, kind, expires_at)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&raw)
|
||||
.bind(AuthTokenKind::RefreshToken)
|
||||
.bind(expires)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<RegisterSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(body.password.as_bytes(), &salt)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.to_string();
|
||||
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (email, password_hash, username, display_name, email_verified)
|
||||
VALUES ($1, $2, $3, $4, false) RETURNING *",
|
||||
)
|
||||
.bind(&body.email)
|
||||
.bind(&hash)
|
||||
.bind(&body.username)
|
||||
.bind(&body.display_name)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let refresh_token = insert_refresh_token(&state, user.id).await?;
|
||||
let access_token = make_access_token(&user.id.to_string(), &state.jwt_secret)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::json!({
|
||||
"user": UserResponse::from(user),
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<LoginSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(&body.email)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?;
|
||||
|
||||
let parsed = PasswordHash::new(&user.password_hash)
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hash parse error".into()))?;
|
||||
Argon2::default()
|
||||
.verify_password(body.password.as_bytes(), &parsed)
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?;
|
||||
|
||||
let refresh_token = insert_refresh_token(&state, user.id).await?;
|
||||
let access_token = make_access_token(&user.id.to_string(), &state.jwt_secret)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"user": UserResponse::from(user),
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<RefreshSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let record = sqlx::query_as::<_, AuthToken>(
|
||||
"SELECT * FROM auth_tokens
|
||||
WHERE token_hash = $1 AND kind = $2 AND expires_at > NOW()",
|
||||
)
|
||||
.bind(&body.refresh_token)
|
||||
.bind(AuthTokenKind::RefreshToken)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Invalid or expired refresh token".into()))?;
|
||||
|
||||
sqlx::query("UPDATE auth_tokens SET used_at = NOW() WHERE id = $1")
|
||||
.bind(record.id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL")
|
||||
.bind(record.user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let access_token = make_access_token(&user.id.to_string(), &state.jwt_secret)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"access_token": access_token,
|
||||
"user": UserResponse::from(user),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<RefreshSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
sqlx::query(
|
||||
"DELETE FROM auth_tokens WHERE token_hash = $1 AND kind = $2",
|
||||
)
|
||||
.bind(&body.refresh_token)
|
||||
.bind(AuthTokenKind::RefreshToken)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
60
src/handlers/auth_token.rs
Normal file
60
src/handlers/auth_token.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use crate::{model::AuthToken, schema::AuthTokenSchema, AppState};
|
||||
use super::db_err;
|
||||
|
||||
pub async fn list_user_tokens(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let tokens = sqlx::query_as::<_, AuthToken>(
|
||||
"SELECT * FROM auth_tokens WHERE user_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(Json(tokens))
|
||||
}
|
||||
|
||||
pub async fn create_token(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<AuthTokenSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
// ponytail: raw uuid used as token, hash with argon2/bcrypt when hardening auth
|
||||
let raw_token = Uuid::new_v4().to_string();
|
||||
let record = sqlx::query_as::<_, AuthToken>(
|
||||
"INSERT INTO auth_tokens (user_id, token_hash, kind, expires_at)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *",
|
||||
)
|
||||
.bind(body.user_id)
|
||||
.bind(&raw_token)
|
||||
.bind(&body.kind)
|
||||
.bind(body.expires_at)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok((StatusCode::CREATED, Json(serde_json::json!({ "token": raw_token, "record": record }))))
|
||||
}
|
||||
|
||||
pub async fn revoke_token(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let res = sqlx::query("DELETE FROM auth_tokens WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
if res.rows_affected() > 0 {
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "Token not found".into()))
|
||||
}
|
||||
}
|
||||
102
src/handlers/conversation.rs
Normal file
102
src/handlers/conversation.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use crate::{model::Conversation, schema::ConversationSchema, AppState};
|
||||
use super::db_err;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConversationFilter {
|
||||
workspace_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn list_conversations(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(filter): Query<ConversationFilter>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let conversations = sqlx::query_as::<_, Conversation>(
|
||||
"SELECT * FROM conversations WHERE deleted_at IS NULL
|
||||
AND ($1::uuid IS NULL OR workspace_id = $1)",
|
||||
)
|
||||
.bind(filter.workspace_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(Json(conversations))
|
||||
}
|
||||
|
||||
pub async fn get_conversation(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
sqlx::query_as::<_, Conversation>(
|
||||
"SELECT * FROM conversations WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.map(|c| Json(c).into_response())
|
||||
.ok_or((StatusCode::NOT_FOUND, "Conversation not found".into()))
|
||||
}
|
||||
|
||||
pub async fn create_conversation(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ConversationSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let conversation = sqlx::query_as::<_, Conversation>(
|
||||
"INSERT INTO conversations (workspace_id, kind, name, is_public, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *",
|
||||
)
|
||||
.bind(body.workspace_id)
|
||||
.bind(&body.kind)
|
||||
.bind(&body.name)
|
||||
.bind(body.is_public)
|
||||
.bind(body.created_by)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok((StatusCode::CREATED, Json(conversation)))
|
||||
}
|
||||
|
||||
pub async fn update_conversation(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<ConversationSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
sqlx::query_as::<_, Conversation>(
|
||||
"UPDATE conversations SET name = $1, is_public = $2, updated_at = NOW()
|
||||
WHERE id = $3 AND deleted_at IS NULL RETURNING *",
|
||||
)
|
||||
.bind(&body.name)
|
||||
.bind(body.is_public)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.map(|c| Json(c).into_response())
|
||||
.ok_or((StatusCode::NOT_FOUND, "Conversation not found".into()))
|
||||
}
|
||||
|
||||
pub async fn delete_conversation(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let res = sqlx::query(
|
||||
"UPDATE conversations SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
if res.rows_affected() > 0 {
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "Conversation not found".into()))
|
||||
}
|
||||
}
|
||||
59
src/handlers/conversation_member.rs
Normal file
59
src/handlers/conversation_member.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use crate::{model::ConversationMember, schema::ConversationMemberSchema, AppState};
|
||||
use super::db_err;
|
||||
|
||||
pub async fn list_members(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(conversation_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let members = sqlx::query_as::<_, ConversationMember>(
|
||||
"SELECT * FROM conversation_members WHERE conversation_id = $1",
|
||||
)
|
||||
.bind(conversation_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(Json(members))
|
||||
}
|
||||
|
||||
pub async fn add_member(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(conversation_id): Path<Uuid>,
|
||||
Json(body): Json<ConversationMemberSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let member = sqlx::query_as::<_, ConversationMember>(
|
||||
"INSERT INTO conversation_members (conversation_id, user_id) VALUES ($1, $2) RETURNING *",
|
||||
)
|
||||
.bind(conversation_id)
|
||||
.bind(body.user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok((StatusCode::CREATED, Json(member)))
|
||||
}
|
||||
|
||||
pub async fn remove_member(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((conversation_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM conversation_members WHERE conversation_id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(conversation_id)
|
||||
.bind(user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
if res.rows_affected() > 0 {
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "Member not found".into()))
|
||||
}
|
||||
}
|
||||
89
src/handlers/message.rs
Normal file
89
src/handlers/message.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use crate::{model::Message, schema::MessageSchema, AppState};
|
||||
use super::db_err;
|
||||
|
||||
pub async fn list_messages(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(conversation_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let messages = sqlx::query_as::<_, Message>(
|
||||
"SELECT * FROM messages WHERE conversation_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(conversation_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(Json(messages))
|
||||
}
|
||||
|
||||
pub async fn get_message(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
sqlx::query_as::<_, Message>("SELECT * FROM messages WHERE id = $1 AND deleted_at IS NULL")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.map(|m| Json(m).into_response())
|
||||
.ok_or((StatusCode::NOT_FOUND, "Message not found".into()))
|
||||
}
|
||||
|
||||
pub async fn create_message(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<MessageSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let message = sqlx::query_as::<_, Message>(
|
||||
"INSERT INTO messages (conversation_id, user_id, content) VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(body.conversation_id)
|
||||
.bind(body.user_id)
|
||||
.bind(&body.content)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok((StatusCode::CREATED, Json(message)))
|
||||
}
|
||||
|
||||
pub async fn update_message(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<MessageSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
sqlx::query_as::<_, Message>(
|
||||
"UPDATE messages SET content = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND deleted_at IS NULL RETURNING *",
|
||||
)
|
||||
.bind(&body.content)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.map(|m| Json(m).into_response())
|
||||
.ok_or((StatusCode::NOT_FOUND, "Message not found".into()))
|
||||
}
|
||||
|
||||
pub async fn delete_message(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let res = sqlx::query(
|
||||
"UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
if res.rows_affected() > 0 {
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "Message not found".into()))
|
||||
}
|
||||
}
|
||||
17
src/handlers/mod.rs
Normal file
17
src/handlers/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use axum::http::StatusCode;
|
||||
|
||||
pub mod attachment;
|
||||
pub mod auth;
|
||||
pub mod auth_token;
|
||||
pub mod conversation;
|
||||
pub mod conversation_member;
|
||||
pub mod message;
|
||||
pub mod reaction;
|
||||
pub mod user;
|
||||
pub mod workspace;
|
||||
pub mod workspace_invitation;
|
||||
pub mod workspace_member;
|
||||
|
||||
pub(crate) fn db_err(e: sqlx::Error) -> (StatusCode, String) {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
}
|
||||
56
src/handlers/reaction.rs
Normal file
56
src/handlers/reaction.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use crate::{model::Reaction, schema::ReactionSchema, AppState};
|
||||
use super::db_err;
|
||||
|
||||
pub async fn list_reactions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(message_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let reactions = sqlx::query_as::<_, Reaction>(
|
||||
"SELECT * FROM reactions WHERE message_id = $1",
|
||||
)
|
||||
.bind(message_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(Json(reactions))
|
||||
}
|
||||
|
||||
pub async fn add_reaction(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ReactionSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let reaction = sqlx::query_as::<_, Reaction>(
|
||||
"INSERT INTO reactions (message_id, user_id, emoji) VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(body.message_id)
|
||||
.bind(body.user_id)
|
||||
.bind(&body.emoji)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok((StatusCode::CREATED, Json(reaction)))
|
||||
}
|
||||
|
||||
pub async fn remove_reaction(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let res = sqlx::query("DELETE FROM reactions WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
if res.rows_affected() > 0 {
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "Reaction not found".into()))
|
||||
}
|
||||
}
|
||||
90
src/handlers/user.rs
Normal file
90
src/handlers/user.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use crate::{model::{User, UserResponse}, schema::UserSchema, AppState};
|
||||
use super::db_err;
|
||||
|
||||
pub async fn list_users(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let users = sqlx::query_as::<_, User>("SELECT * FROM users WHERE deleted_at IS NULL")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(Json(users.into_iter().map(UserResponse::from).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
pub async fn get_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.map(|u| Json(UserResponse::from(u)).into_response())
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".into()))
|
||||
}
|
||||
|
||||
pub async fn create_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<UserSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (email, password_hash, username, display_name, avatar_url, bio, email_verified)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
|
||||
)
|
||||
.bind(&body.email)
|
||||
.bind(&body.password_hash)
|
||||
.bind(&body.username)
|
||||
.bind(&body.display_name)
|
||||
.bind(&body.avatar_url)
|
||||
.bind(&body.bio)
|
||||
.bind(body.email_verified)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok((StatusCode::CREATED, Json(UserResponse::from(user))))
|
||||
}
|
||||
|
||||
pub async fn update_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<UserSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"UPDATE users SET display_name = $1, avatar_url = $2, bio = $3, updated_at = NOW()
|
||||
WHERE id = $4 AND deleted_at IS NULL RETURNING *",
|
||||
)
|
||||
.bind(&body.display_name)
|
||||
.bind(&body.avatar_url)
|
||||
.bind(&body.bio)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.map(|u| Json(UserResponse::from(u)).into_response())
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".into()))
|
||||
}
|
||||
|
||||
pub async fn delete_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let res = sqlx::query("UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
if res.rows_affected() > 0 {
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "User not found".into()))
|
||||
}
|
||||
}
|
||||
84
src/handlers/workspace.rs
Normal file
84
src/handlers/workspace.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use crate::{model::Workspace, schema::WorkspaceSchema, AppState};
|
||||
use super::db_err;
|
||||
|
||||
pub async fn list_workspaces(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let workspaces = sqlx::query_as::<_, Workspace>("SELECT * FROM workspaces WHERE deleted_at IS NULL")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(Json(workspaces))
|
||||
}
|
||||
|
||||
pub async fn get_workspace(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
sqlx::query_as::<_, Workspace>("SELECT * FROM workspaces WHERE id = $1 AND deleted_at IS NULL")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.map(|w| Json(w).into_response())
|
||||
.ok_or((StatusCode::NOT_FOUND, "Workspace not found".into()))
|
||||
}
|
||||
|
||||
pub async fn create_workspace(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<WorkspaceSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let workspace = sqlx::query_as::<_, Workspace>(
|
||||
"INSERT INTO workspaces (name, slug, owner_id) VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(&body.name)
|
||||
.bind(&body.slug)
|
||||
.bind(body.owner_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok((StatusCode::CREATED, Json(workspace)))
|
||||
}
|
||||
|
||||
pub async fn update_workspace(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<WorkspaceSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
sqlx::query_as::<_, Workspace>(
|
||||
"UPDATE workspaces SET name = $1, slug = $2, updated_at = NOW()
|
||||
WHERE id = $3 AND deleted_at IS NULL RETURNING *",
|
||||
)
|
||||
.bind(&body.name)
|
||||
.bind(&body.slug)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.map(|w| Json(w).into_response())
|
||||
.ok_or((StatusCode::NOT_FOUND, "Workspace not found".into()))
|
||||
}
|
||||
|
||||
pub async fn delete_workspace(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let res = sqlx::query("UPDATE workspaces SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
if res.rows_affected() > 0 {
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "Workspace not found".into()))
|
||||
}
|
||||
}
|
||||
61
src/handlers/workspace_invitation.rs
Normal file
61
src/handlers/workspace_invitation.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use crate::{model::WorkspaceInvitation, schema::WorkspaceInvitationSchema, AppState};
|
||||
use super::db_err;
|
||||
|
||||
pub async fn list_invitations(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(workspace_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let invitations = sqlx::query_as::<_, WorkspaceInvitation>(
|
||||
"SELECT * FROM workspace_invitations WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(Json(invitations))
|
||||
}
|
||||
|
||||
pub async fn create_invitation(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(workspace_id): Path<Uuid>,
|
||||
Json(body): Json<WorkspaceInvitationSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let token = Uuid::new_v4().to_string();
|
||||
let invitation = sqlx::query_as::<_, WorkspaceInvitation>(
|
||||
"INSERT INTO workspace_invitations (workspace_id, created_by, token, max_uses, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(body.created_by)
|
||||
.bind(&token)
|
||||
.bind(body.max_uses)
|
||||
.bind(body.expires_at)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok((StatusCode::CREATED, Json(invitation)))
|
||||
}
|
||||
|
||||
pub async fn delete_invitation(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let res = sqlx::query("DELETE FROM workspace_invitations WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
if res.rows_affected() > 0 {
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "Invitation not found".into()))
|
||||
}
|
||||
}
|
||||
82
src/handlers/workspace_member.rs
Normal file
82
src/handlers/workspace_member.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use crate::{model::WorkspaceMember, schema::WorkspaceMemberSchema, AppState};
|
||||
use super::db_err;
|
||||
|
||||
pub async fn list_members(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(workspace_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let members = sqlx::query_as::<_, WorkspaceMember>(
|
||||
"SELECT * FROM workspace_members WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(Json(members))
|
||||
}
|
||||
|
||||
pub async fn add_member(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(workspace_id): Path<Uuid>,
|
||||
Json(body): Json<WorkspaceMemberSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let member = sqlx::query_as::<_, WorkspaceMember>(
|
||||
"INSERT INTO workspace_members (workspace_id, user_id, role, display_name, avatar_url)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(body.user_id)
|
||||
.bind(&body.role)
|
||||
.bind(&body.display_name)
|
||||
.bind(&body.avatar_url)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok((StatusCode::CREATED, Json(member)))
|
||||
}
|
||||
|
||||
pub async fn update_member(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((workspace_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
Json(body): Json<WorkspaceMemberSchema>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
sqlx::query_as::<_, WorkspaceMember>(
|
||||
"UPDATE workspace_members SET role = $1, display_name = $2, avatar_url = $3
|
||||
WHERE workspace_id = $4 AND user_id = $5 RETURNING *",
|
||||
)
|
||||
.bind(&body.role)
|
||||
.bind(&body.display_name)
|
||||
.bind(&body.avatar_url)
|
||||
.bind(workspace_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.map(|m| Json(m).into_response())
|
||||
.ok_or((StatusCode::NOT_FOUND, "Member not found".into()))
|
||||
}
|
||||
|
||||
pub async fn remove_member(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((workspace_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let res = sqlx::query("DELETE FROM workspace_members WHERE workspace_id = $1 AND user_id = $2")
|
||||
.bind(workspace_id)
|
||||
.bind(user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
if res.rows_affected() > 0 {
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "Member not found".into()))
|
||||
}
|
||||
}
|
||||
51
src/main.rs
Normal file
51
src/main.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
mod handlers;
|
||||
mod middleware;
|
||||
mod model;
|
||||
mod route;
|
||||
mod schema;
|
||||
|
||||
use dotenv::dotenv;
|
||||
use sqlx::{PgPool, postgres::PgPoolOptions};
|
||||
|
||||
pub struct AppState {
|
||||
pub db: PgPool,
|
||||
pub jwt_secret: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
dotenv().ok();
|
||||
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set!");
|
||||
let pool = match PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect(&db_url)
|
||||
.await
|
||||
{
|
||||
Ok(pool) => {
|
||||
println!("Connected to DB successfully!");
|
||||
pool
|
||||
}
|
||||
Err(err) => {
|
||||
println!("Failed to connect to DB: {}", err);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set!");
|
||||
let ip_addr: String = std::env::var("API_URL").expect("API_URL must be set!");
|
||||
let port: String = std::env::var("API_PORT").expect("API_PORT must be set!");
|
||||
let addr: String = format!("{}:{}", ip_addr, port);
|
||||
|
||||
let state = Arc::new(AppState { db: pool, jwt_secret });
|
||||
let app: Router = Router::new()
|
||||
.nest("/api/v1", route::router(state.clone()))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
println!("Server started successfully at {}", addr);
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
33
src/middleware.rs
Normal file
33
src/middleware.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{model::Claims, AppState};
|
||||
|
||||
pub async fn require_auth(
|
||||
State(state): State<Arc<AppState>>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let token = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header".into()))?;
|
||||
|
||||
let data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid or expired token".into()))?;
|
||||
|
||||
req.extensions_mut().insert(data.claims);
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
173
src/model.rs
Normal file
173
src/model.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
// ── Enums ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "auth_token_kind", rename_all = "snake_case")]
|
||||
pub enum AuthTokenKind {
|
||||
PasswordReset,
|
||||
EmailVerification,
|
||||
RefreshToken,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "workspace_role", rename_all = "snake_case")]
|
||||
pub enum WorkspaceRole {
|
||||
Owner,
|
||||
Admin,
|
||||
Member,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "conversation_kind", rename_all = "snake_case")]
|
||||
pub enum ConversationKind {
|
||||
Dm,
|
||||
Group,
|
||||
Channel,
|
||||
}
|
||||
|
||||
// ── Tables ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<User> for UserResponse {
|
||||
fn from(u: User) -> Self {
|
||||
Self {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
username: u.username,
|
||||
display_name: u.display_name,
|
||||
avatar_url: u.avatar_url,
|
||||
bio: u.bio,
|
||||
email_verified: u.email_verified,
|
||||
created_at: u.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct AuthToken {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub token_hash: String,
|
||||
pub kind: AuthTokenKind,
|
||||
pub expires_at: chrono::DateTime<chrono::Utc>,
|
||||
pub used_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct Workspace {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub owner_id: Uuid,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct WorkspaceMember {
|
||||
pub workspace_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub role: WorkspaceRole,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub joined_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct WorkspaceInvitation {
|
||||
pub id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub created_by: Uuid,
|
||||
pub token: String,
|
||||
pub max_uses: Option<i32>,
|
||||
pub use_count: i32,
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct Conversation {
|
||||
pub id: Uuid,
|
||||
pub workspace_id: Option<Uuid>,
|
||||
pub kind: ConversationKind,
|
||||
pub name: Option<String>,
|
||||
pub is_public: bool,
|
||||
pub created_by: Uuid,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct ConversationMember {
|
||||
pub conversation_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub joined_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct Message {
|
||||
pub id: Uuid,
|
||||
pub conversation_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub content: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct Attachment {
|
||||
pub id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
pub url: String,
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
pub size_bytes: i64,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
|
||||
pub struct Reaction {
|
||||
pub id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub emoji: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
107
src/route.rs
Normal file
107
src/route.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use crate::handlers::{
|
||||
attachment, auth, auth_token, conversation, conversation_member, message, reaction, user,
|
||||
workspace, workspace_invitation, workspace_member,
|
||||
};
|
||||
use crate::middleware::require_auth;
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
middleware,
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn router(state: Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
let public = Router::new()
|
||||
.route("/auth/register", post(auth::register))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/refresh", post(auth::refresh))
|
||||
.route("/auth/logout", post(auth::logout));
|
||||
|
||||
let protected = Router::new()
|
||||
// Users
|
||||
.route("/users", get(user::list_users).post(user::create_user))
|
||||
.route(
|
||||
"/users/{id}",
|
||||
get(user::get_user)
|
||||
.put(user::update_user)
|
||||
.delete(user::delete_user),
|
||||
)
|
||||
// Auth tokens (admin)
|
||||
.route("/users/{id}/auth-tokens", get(auth_token::list_user_tokens))
|
||||
.route("/auth-tokens", post(auth_token::create_token))
|
||||
.route("/auth-tokens/{id}", delete(auth_token::revoke_token))
|
||||
// Workspaces
|
||||
.route(
|
||||
"/workspaces",
|
||||
get(workspace::list_workspaces).post(workspace::create_workspace),
|
||||
)
|
||||
.route(
|
||||
"/workspaces/{id}",
|
||||
get(workspace::get_workspace)
|
||||
.put(workspace::update_workspace)
|
||||
.delete(workspace::delete_workspace),
|
||||
)
|
||||
// Workspace members
|
||||
.route(
|
||||
"/workspaces/{id}/members",
|
||||
get(workspace_member::list_members).post(workspace_member::add_member),
|
||||
)
|
||||
.route(
|
||||
"/workspaces/{workspace_id}/members/{user_id}",
|
||||
put(workspace_member::update_member).delete(workspace_member::remove_member),
|
||||
)
|
||||
// Workspace invitations
|
||||
.route(
|
||||
"/workspaces/{id}/invitations",
|
||||
get(workspace_invitation::list_invitations)
|
||||
.post(workspace_invitation::create_invitation),
|
||||
)
|
||||
.route(
|
||||
"/invitations/{id}",
|
||||
delete(workspace_invitation::delete_invitation),
|
||||
)
|
||||
// Conversations
|
||||
.route(
|
||||
"/conversations",
|
||||
get(conversation::list_conversations).post(conversation::create_conversation),
|
||||
)
|
||||
.route(
|
||||
"/conversations/{id}",
|
||||
get(conversation::get_conversation)
|
||||
.put(conversation::update_conversation)
|
||||
.delete(conversation::delete_conversation),
|
||||
)
|
||||
// Conversation members
|
||||
.route(
|
||||
"/conversations/{id}/members",
|
||||
get(conversation_member::list_members).post(conversation_member::add_member),
|
||||
)
|
||||
.route(
|
||||
"/conversations/{conversation_id}/members/{user_id}",
|
||||
delete(conversation_member::remove_member),
|
||||
)
|
||||
// Messages
|
||||
.route("/conversations/{id}/messages", get(message::list_messages))
|
||||
.route("/messages", post(message::create_message))
|
||||
.route(
|
||||
"/messages/{id}",
|
||||
get(message::get_message)
|
||||
.put(message::update_message)
|
||||
.delete(message::delete_message),
|
||||
)
|
||||
// Attachments
|
||||
.route(
|
||||
"/messages/{id}/attachments",
|
||||
get(attachment::list_attachments),
|
||||
)
|
||||
.route("/attachments", post(attachment::create_attachment))
|
||||
.route("/attachments/{id}", delete(attachment::delete_attachment))
|
||||
// Reactions
|
||||
.route("/messages/{id}/reactions", get(reaction::list_reactions))
|
||||
.route("/reactions", post(reaction::add_reaction))
|
||||
.route("/reactions/{id}", delete(reaction::remove_reaction))
|
||||
.layer(middleware::from_fn_with_state(state, require_auth));
|
||||
|
||||
Router::new().merge(public).merge(protected)
|
||||
}
|
||||
103
src/schema.rs
Normal file
103
src/schema.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::{AuthTokenKind, ConversationKind, WorkspaceRole};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RegisterSchema {
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginSchema {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RefreshSchema {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct UserSchema {
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub email_verified: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct AuthTokenSchema {
|
||||
pub user_id: Uuid,
|
||||
pub kind: AuthTokenKind,
|
||||
pub expires_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct WorkspaceSchema {
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub owner_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct WorkspaceMemberSchema {
|
||||
pub workspace_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub role: WorkspaceRole,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct WorkspaceInvitationSchema {
|
||||
pub workspace_id: Uuid,
|
||||
pub created_by: Uuid,
|
||||
pub max_uses: Option<i32>,
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct ConversationSchema {
|
||||
pub workspace_id: Option<Uuid>,
|
||||
pub kind: ConversationKind,
|
||||
pub name: Option<String>,
|
||||
pub is_public: bool,
|
||||
pub created_by: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct ConversationMemberSchema {
|
||||
pub conversation_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct MessageSchema {
|
||||
pub conversation_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct AttachmentSchema {
|
||||
pub message_id: Uuid,
|
||||
pub url: String,
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
pub size_bytes: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct ReactionSchema {
|
||||
pub message_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub emoji: String,
|
||||
}
|
||||
Reference in New Issue
Block a user