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