feat: initial commit

Axum/Postgres server with auth, migrations, and Docker setup.
This commit is contained in:
2026-08-13 13:08:13 +02:00
commit 9617f4415c
33 changed files with 3974 additions and 0 deletions

View 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
View 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)
}

View 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()))
}
}

View 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()))
}
}

View 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
View 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
View 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
View 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
View 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
View 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()))
}
}

View 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()))
}
}

View 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
View 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
View 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
View 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
View 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
View 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,
}