feat: initial commit
Axum/Postgres server with auth, migrations, and Docker setup.
This commit is contained in:
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
.env
|
||||
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
POSTGRES_DB=rustification
|
||||
POSTGRES_USER=admin
|
||||
POSTGRES_PASSWORD=password
|
||||
|
||||
DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}
|
||||
|
||||
API_URL=0.0.0.0
|
||||
API_PORT=3000
|
||||
|
||||
JWT_SECRET=XXXX
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
.env
|
||||
2148
Cargo.lock
generated
Normal file
2148
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "rustification-server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8.9"
|
||||
dotenv = "0.15.0"
|
||||
serde_json = "1.0.151"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sqlx = { version = "0.9", features = ["runtime-tokio", "tls-rustls-ring-webpki", "postgres", "chrono", "uuid"] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
chrono = { version = "0.4.41", features = ["serde"] }
|
||||
uuid = { version = "1.17.0", features = ["serde", "v4"] }
|
||||
argon2 = "0.5"
|
||||
jsonwebtoken = "9"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM rust:1-slim-bookworm AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Cache dependencies before copying source
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
RUN mkdir src && echo "fn main() {}" > src/main.rs \
|
||||
&& cargo build --release \
|
||||
&& rm -rf src
|
||||
|
||||
COPY src ./src
|
||||
# Touch main.rs so cargo knows the real source changed
|
||||
RUN touch src/main.rs && cargo build --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/target/release/rustification-server /usr/local/bin/server
|
||||
EXPOSE 3000
|
||||
CMD ["server"]
|
||||
31
compose.yaml
Normal file
31
compose.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
container_name: rustification-postgres
|
||||
env_file:
|
||||
- ./.env
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- postgresDb:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
server:
|
||||
build: .
|
||||
container_name: rustification-server
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
ports:
|
||||
- '3000:3000'
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgresDb:
|
||||
230
docs/api.md
Normal file
230
docs/api.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# API Reference
|
||||
|
||||
Base URL: `http://localhost:3000/api/v1`
|
||||
|
||||
---
|
||||
|
||||
## Users
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/users` | List all users |
|
||||
| POST | `/users` | Create a user |
|
||||
| GET | `/users/{id}` | Get a user |
|
||||
| PUT | `/users/{id}` | Update a user (display_name, avatar_url, bio) |
|
||||
| DELETE | `/users/{id}` | Soft-delete a user |
|
||||
|
||||
**POST /users — body**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password_hash": "...",
|
||||
"username": "jane",
|
||||
"display_name": "Jane",
|
||||
"avatar_url": null,
|
||||
"bio": null,
|
||||
"email_verified": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auth Tokens
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/users/{id}/auth-tokens` | List tokens for a user |
|
||||
| POST | `/auth-tokens` | Create a token |
|
||||
| DELETE | `/auth-tokens/{id}` | Revoke a token |
|
||||
|
||||
**POST /auth-tokens — body**
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"kind": "refresh_token",
|
||||
"expires_at": "2026-09-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
`kind`: `password_reset` | `email_verification` | `refresh_token`
|
||||
|
||||
**POST /auth-tokens — response** includes `token` (raw value) and `record`.
|
||||
|
||||
---
|
||||
|
||||
## Workspaces
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/workspaces` | List all workspaces |
|
||||
| POST | `/workspaces` | Create a workspace |
|
||||
| GET | `/workspaces/{id}` | Get a workspace |
|
||||
| PUT | `/workspaces/{id}` | Update a workspace (name, slug) |
|
||||
| DELETE | `/workspaces/{id}` | Soft-delete a workspace |
|
||||
|
||||
**POST /workspaces — body**
|
||||
```json
|
||||
{
|
||||
"name": "Acme",
|
||||
"slug": "acme",
|
||||
"owner_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workspace Members
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/workspaces/{id}/members` | List members |
|
||||
| POST | `/workspaces/{id}/members` | Add a member |
|
||||
| PUT | `/workspaces/{workspace_id}/members/{user_id}` | Update role / display name |
|
||||
| DELETE | `/workspaces/{workspace_id}/members/{user_id}` | Remove a member |
|
||||
|
||||
**POST body**
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"role": "member",
|
||||
"display_name": null,
|
||||
"avatar_url": null
|
||||
}
|
||||
```
|
||||
`role`: `owner` | `admin` | `member`
|
||||
|
||||
---
|
||||
|
||||
## Workspace Invitations
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/workspaces/{id}/invitations` | List invitations |
|
||||
| POST | `/workspaces/{id}/invitations` | Create an invitation |
|
||||
| DELETE | `/invitations/{id}` | Delete an invitation |
|
||||
|
||||
**POST body**
|
||||
```json
|
||||
{
|
||||
"created_by": "uuid",
|
||||
"max_uses": 10,
|
||||
"expires_at": "2026-09-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
`max_uses` and `expires_at` are optional. The invite `token` is generated server-side.
|
||||
|
||||
---
|
||||
|
||||
## Conversations
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/conversations` | List conversations (`?workspace_id=uuid`) |
|
||||
| POST | `/conversations` | Create a conversation |
|
||||
| GET | `/conversations/{id}` | Get a conversation |
|
||||
| PUT | `/conversations/{id}` | Update a conversation (name, is_public) |
|
||||
| DELETE | `/conversations/{id}` | Soft-delete a conversation |
|
||||
|
||||
**POST body**
|
||||
```json
|
||||
{
|
||||
"workspace_id": "uuid",
|
||||
"kind": "channel",
|
||||
"name": "general",
|
||||
"is_public": true,
|
||||
"created_by": "uuid"
|
||||
}
|
||||
```
|
||||
`kind`: `dm` | `group` | `channel`
|
||||
`workspace_id` is optional (null for DMs).
|
||||
|
||||
---
|
||||
|
||||
## Conversation Members
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/conversations/{id}/members` | List members |
|
||||
| POST | `/conversations/{id}/members` | Add a member |
|
||||
| DELETE | `/conversations/{conversation_id}/members/{user_id}` | Remove a member |
|
||||
|
||||
**POST body**
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Messages
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/conversations/{id}/messages` | List messages (chronological) |
|
||||
| POST | `/messages` | Send a message |
|
||||
| GET | `/messages/{id}` | Get a message |
|
||||
| PUT | `/messages/{id}` | Edit a message (content) |
|
||||
| DELETE | `/messages/{id}` | Soft-delete a message |
|
||||
|
||||
**POST body**
|
||||
```json
|
||||
{
|
||||
"conversation_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"content": "Hello!"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Attachments
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/messages/{id}/attachments` | List attachments for a message |
|
||||
| POST | `/attachments` | Create an attachment |
|
||||
| DELETE | `/attachments/{id}` | Delete an attachment |
|
||||
|
||||
**POST body**
|
||||
```json
|
||||
{
|
||||
"message_id": "uuid",
|
||||
"url": "https://...",
|
||||
"filename": "file.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": 102400
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reactions
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/messages/{id}/reactions` | List reactions for a message |
|
||||
| POST | `/reactions` | Add a reaction |
|
||||
| DELETE | `/reactions/{id}` | Remove a reaction |
|
||||
|
||||
**POST body**
|
||||
```json
|
||||
{
|
||||
"message_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"emoji": "👍"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response conventions
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| 200 | OK |
|
||||
| 201 | Created |
|
||||
| 204 | Deleted (no body) |
|
||||
| 404 | Not found |
|
||||
| 500 | Internal server error (body: error string) |
|
||||
|
||||
Soft-deleted resources (`users`, `workspaces`, `conversations`, `messages`) are excluded from all list/get responses.
|
||||
3
migrations/20260812114905_create_user_table.down.sql
Normal file
3
migrations/20260812114905_create_user_table.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS auth_tokens;
|
||||
DROP TYPE IF EXISTS auth_token_kind;
|
||||
DROP TABLE IF EXISTS users;
|
||||
28
migrations/20260812114905_create_user_table.up.sql
Normal file
28
migrations/20260812114905_create_user_table.up.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
bio TEXT,
|
||||
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TYPE auth_token_kind AS ENUM ('password_reset', 'email_verification', 'refresh_token');
|
||||
|
||||
CREATE TABLE auth_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL,
|
||||
kind auth_token_kind NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX auth_tokens_user_id_idx ON auth_tokens(user_id);
|
||||
CREATE INDEX auth_tokens_token_hash_idx ON auth_tokens(token_hash);
|
||||
4
migrations/20260812120000_create_workspaces.down.sql
Normal file
4
migrations/20260812120000_create_workspaces.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS workspace_invitations;
|
||||
DROP TABLE IF EXISTS workspace_members;
|
||||
DROP TABLE IF EXISTS workspaces;
|
||||
DROP TYPE IF EXISTS workspace_role;
|
||||
34
migrations/20260812120000_create_workspaces.up.sql
Normal file
34
migrations/20260812120000_create_workspaces.up.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
CREATE TYPE workspace_role AS ENUM ('owner', 'admin', 'member');
|
||||
|
||||
CREATE TABLE workspaces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
owner_id UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE workspace_members (
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role workspace_role NOT NULL DEFAULT 'member',
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (workspace_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE workspace_invitations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
max_uses INT,
|
||||
use_count INT NOT NULL DEFAULT 0,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX workspace_members_user_id_idx ON workspace_members(user_id);
|
||||
3
migrations/20260812130000_create_conversations.down.sql
Normal file
3
migrations/20260812130000_create_conversations.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS conversation_members;
|
||||
DROP TABLE IF EXISTS conversations;
|
||||
DROP TYPE IF EXISTS conversation_kind;
|
||||
23
migrations/20260812130000_create_conversations.up.sql
Normal file
23
migrations/20260812130000_create_conversations.up.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TYPE conversation_kind AS ENUM ('dm', 'group', 'channel');
|
||||
|
||||
CREATE TABLE conversations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
kind conversation_kind NOT NULL,
|
||||
name TEXT,
|
||||
is_public BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE conversation_members (
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (conversation_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX conversations_workspace_id_idx ON conversations(workspace_id);
|
||||
CREATE INDEX conversation_members_user_id_idx ON conversation_members(user_id);
|
||||
3
migrations/20260812140000_create_messages.down.sql
Normal file
3
migrations/20260812140000_create_messages.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS reactions;
|
||||
DROP TABLE IF EXISTS attachments;
|
||||
DROP TABLE IF EXISTS messages;
|
||||
31
migrations/20260812140000_create_messages.up.sql
Normal file
31
migrations/20260812140000_create_messages.up.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
CREATE TABLE messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE reactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (message_id, user_id, emoji)
|
||||
);
|
||||
|
||||
CREATE INDEX messages_conversation_id_created_at_idx ON messages(conversation_id, created_at DESC);
|
||||
CREATE INDEX reactions_message_id_idx ON reactions(message_id);
|
||||
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