Compare commits
83 Commits
6e63dca513
...
master
Author | SHA1 | Date | |
---|---|---|---|
dffec9b189 | |||
e2494135d6 | |||
d6c42afaec | |||
e376f584c7 | |||
75c5adf346 | |||
878ebf1541 | |||
c9775293c0 | |||
93b90b85b6 | |||
58e51cb028 | |||
5282376860 | |||
082f11a3e9 | |||
ec73a0c373 | |||
29afc2e92e | |||
cbca1058a2 | |||
8536e52590 | |||
247c6ad955 | |||
c6f7dfe225 | |||
0ba3b79185 | |||
64806f8bd4 | |||
4ea4f3149f | |||
d92c9a747e | |||
863bc90c6f | |||
d15339cf4a | |||
916dbe0245 | |||
7889137cd8 | |||
4e38c1133e | |||
86eb059f3e | |||
84f2423343 | |||
9207572f07 | |||
1c52bf3ea4 | |||
327e671571 | |||
36e12d1d96 | |||
452ea5625f | |||
bc8941d910 | |||
01d7a837f8 | |||
71048f0060 | |||
f278a44d8f | |||
aa4be7e05b | |||
5bc4337447 | |||
b50b7bcc73 | |||
9b2a1139b5 | |||
2083f3bb16 | |||
08213133be | |||
5f8cf49ec9 | |||
c6f5bab1eb | |||
72b4cb0851 | |||
dd279a1434 | |||
6efab333f3 | |||
1a405500ca | |||
3d25ffca4f | |||
5ce6d9f2da | |||
40695b7ad3 | |||
b337184a59 | |||
862974bb35 | |||
8b14ab06a2 | |||
e1b5a2aaa0 | |||
c9b8bd7b07 | |||
69eb225c1e | |||
c3539cfc11 | |||
f1e891413a | |||
c520690f1e | |||
8ddbf45a09 | |||
dc92945962 | |||
bf7c6501c6 | |||
85e3425d4b | |||
5344e0d6a8 | |||
8b82a5e48e | |||
bf2e280cdd | |||
8a4c07b3f6 | |||
19520c832f | |||
fc7dacc6fb | |||
7348433b9c | |||
8552858c8c | |||
c7cb3f537d | |||
e7cf76a0d8 | |||
38e107ad59 | |||
6aef739438 | |||
82c6de8da8 | |||
0abd275946 | |||
728bf0e231 | |||
508f218fc0 | |||
b83b7acf1c | |||
c9e99e6f23 |
9
.env
9
.env
@@ -1,3 +1,10 @@
|
|||||||
POSTGRES_USER=thoughts_user
|
POSTGRES_USER=thoughts_user
|
||||||
POSTGRES_PASSWORD=postgres
|
POSTGRES_PASSWORD=postgres
|
||||||
POSTGRES_DB=thoughts_db
|
POSTGRES_DB=thoughts_db
|
||||||
|
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
DATABASE_URL="postgresql://thoughts_user:postgres@database/thoughts_db"
|
||||||
|
PREFORK=1
|
||||||
|
AUTH_SECRET=secret
|
||||||
|
BASE_URL=http://0.0.0.0
|
41
.gitea/workflows/deploy.yml
Normal file
41
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Build and Deploy Thoughts
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy-local:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Create .env file
|
||||||
|
run: |
|
||||||
|
echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env
|
||||||
|
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
|
||||||
|
echo "POSTGRES_DB=${{ secrets.POSTGRES_DB }}" >> .env
|
||||||
|
echo "AUTH_SECRET=${{ secrets.AUTH_SECRET }}" >> .env
|
||||||
|
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" >> .env
|
||||||
|
|
||||||
|
- name: Build Docker Images Manually
|
||||||
|
run: |
|
||||||
|
docker build --target runtime -t thoughts-backend:latest ./thoughts-backend
|
||||||
|
docker build --target release -t thoughts-frontend:latest --build-arg NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} ./thoughts-frontend
|
||||||
|
docker build -t custom-proxy:latest ./nginx
|
||||||
|
|
||||||
|
- name: Deploy with Docker Compose
|
||||||
|
run: |
|
||||||
|
docker compose -f compose.prod.yml down
|
||||||
|
|
||||||
|
POSTGRES_USER=${{ secrets.POSTGRES_USER }} \
|
||||||
|
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \
|
||||||
|
POSTGRES_DB=${{ secrets.POSTGRES_DB }} \
|
||||||
|
AUTH_SECRET=${{ secrets.AUTH_SECRET }} \
|
||||||
|
docker compose -f compose.prod.yml up -d
|
||||||
|
|
||||||
|
docker image prune -f
|
91
compose.prod.yml
Normal file
91
compose.prod.yml
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
services:
|
||||||
|
database:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: thoughts-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
backend:
|
||||||
|
container_name: thoughts-backend
|
||||||
|
image: thoughts-backend:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- RUST_LOG=info
|
||||||
|
- RUST_BACKTRACE=1
|
||||||
|
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DB}
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
- PORT=8000
|
||||||
|
- PREFORK=1
|
||||||
|
- AUTH_SECRET=${AUTH_SECRET}
|
||||||
|
- BASE_URL=https://thoughts.gabrielkaszewski.dev
|
||||||
|
depends_on:
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
container_name: thoughts-frontend
|
||||||
|
image: thoughts-frontend:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_SERVER_SIDE_API_URL=http://proxy/api
|
||||||
|
- PORT=3000
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
container_name: thoughts-proxy
|
||||||
|
image: custom-proxy:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
frontend:
|
||||||
|
condition: service_healthy
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik"
|
||||||
|
- "traefik.http.routers.thoughts.rule=Host(`thoughts.gabrielkaszewski.dev`)"
|
||||||
|
- "traefik.http.routers.thoughts.entrypoints=web,websecure"
|
||||||
|
- "traefik.http.routers.thoughts.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.thoughts.service=thoughts"
|
||||||
|
- "traefik.http.services.thoughts.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
name: traefik
|
||||||
|
external: true
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
22
compose.yml
22
compose.yml
@@ -25,6 +25,9 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
environment:
|
||||||
|
- RUST_LOG=info
|
||||||
|
- RUST_BACKTRACE=1
|
||||||
depends_on:
|
depends_on:
|
||||||
database:
|
database:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -34,9 +37,13 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./thoughts-frontend
|
context: ./thoughts-frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_API_URL: http://localhost/api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_SERVER_SIDE_API_URL=http://proxy/api
|
||||||
|
|
||||||
proxy:
|
proxy:
|
||||||
container_name: thoughts-proxy
|
container_name: thoughts-proxy
|
||||||
@@ -50,6 +57,21 @@ services:
|
|||||||
- frontend
|
- frontend
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
db_test:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: thoughts-db-test
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
ports:
|
||||||
|
- "5434:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
5
nginx/Dockerfile
Normal file
5
nginx/Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
FROM nginx:stable-alpine
|
||||||
|
|
||||||
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
@@ -10,6 +10,11 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
return 200 "OK";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
proxy_connect_timeout 300s;
|
proxy_connect_timeout 300s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
|
@@ -5,3 +5,4 @@ DATABASE_URL="postgresql://postgres:postgres@localhost/thoughts"
|
|||||||
#DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db
|
#DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db
|
||||||
PREFORK=0
|
PREFORK=0
|
||||||
AUTH_SECRET=your_secret_key_here
|
AUTH_SECRET=your_secret_key_here
|
||||||
|
BASE_URL=http://0.0.0.0
|
@@ -1,7 +1,6 @@
|
|||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=3000
|
PORT=3000
|
||||||
DATABASE_URL="sqlite://dev.db"
|
DATABASE_URL="postgresql://postgres:postgres@localhost/clean-axum"
|
||||||
# DATABASE_URL="postgresql://postgres:postgres@localhost/clean-axum"
|
|
||||||
PREFORK=1
|
PREFORK=1
|
||||||
AUTH_SECRET=your_secret_key_here
|
AUTH_SECRET=your_secret_key_here
|
||||||
BASE_URL=http://localhost:3000
|
BASE_URL=http://localhost:3000
|
710
thoughts-backend/Cargo.lock
generated
710
thoughts-backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -17,13 +17,14 @@ members = ["api", "app", "doc", "models", "migration", "utils"]
|
|||||||
tower = { version = "0.5.2", default-features = false }
|
tower = { version = "0.5.2", default-features = false }
|
||||||
axum = { version = "0.8.4", default-features = false }
|
axum = { version = "0.8.4", default-features = false }
|
||||||
sea-orm = { version = "1.1.12" }
|
sea-orm = { version = "1.1.12" }
|
||||||
sea-query = { version = "0.32.6" } # Added sea-query dependency
|
sea-query = { version = "0.32.6" } # Added sea-query dependency
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_json = { version = "1.0.140", features = ["raw_value"] }
|
serde_json = { version = "1.0.140", features = ["raw_value"] }
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
utoipa = { version = "5.4.0", features = ["macros", "chrono"] }
|
utoipa = { version = "5.4.0", features = ["macros", "chrono", "uuid"] }
|
||||||
validator = { version = "0.20.0", default-features = false }
|
validator = { version = "0.20.0", default-features = false }
|
||||||
chrono = { version = "0.4.41", features = ["serde"] }
|
chrono = { version = "0.4.41", features = ["serde"] }
|
||||||
|
tokio = { version = "1.45.1", features = ["full"] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
api = { path = "api" }
|
api = { path = "api" }
|
||||||
@@ -38,8 +39,8 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
|||||||
|
|
||||||
# runtime
|
# runtime
|
||||||
axum = { workspace = true, features = ["tokio", "http1", "http2"] }
|
axum = { workspace = true, features = ["tokio", "http1", "http2"] }
|
||||||
tokio = { version = "1.45.1", features = ["full"] }
|
|
||||||
prefork = { version = "0.6.0", default-features = false, optional = true }
|
prefork = { version = "0.6.0", default-features = false, optional = true }
|
||||||
|
tokio = { version = "1.45.1", features = ["full"] }
|
||||||
|
|
||||||
# shuttle runtime
|
# shuttle runtime
|
||||||
shuttle-axum = { version = "0.55.0", optional = true }
|
shuttle-axum = { version = "0.55.0", optional = true }
|
||||||
|
@@ -1,10 +1,14 @@
|
|||||||
FROM rust:1.89-slim AS builder
|
FROM rust:1.89-slim AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y libssl-dev pkg-config && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN cargo install cargo-chef --locked
|
RUN cargo install cargo-chef --locked
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY api/Cargo.toml ./api/
|
COPY api/Cargo.toml ./api/
|
||||||
COPY app/Cargo.toml ./app/
|
COPY app/Cargo.toml ./app/
|
||||||
|
COPY common/Cargo.toml ./common/
|
||||||
COPY doc/Cargo.toml ./doc/
|
COPY doc/Cargo.toml ./doc/
|
||||||
COPY migration/Cargo.toml ./migration/
|
COPY migration/Cargo.toml ./migration/
|
||||||
COPY models/Cargo.toml ./models/
|
COPY models/Cargo.toml ./models/
|
||||||
@@ -22,6 +26,8 @@ RUN cargo build --release --bin thoughts-backend
|
|||||||
|
|
||||||
FROM debian:13-slim AS runtime
|
FROM debian:13-slim AS runtime
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl wget && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN groupadd --system --gid 1001 appgroup && \
|
RUN groupadd --system --gid 1001 appgroup && \
|
||||||
useradd --system --uid 1001 --gid appgroup appuser
|
useradd --system --uid 1001 --gid appgroup appuser
|
||||||
|
|
||||||
@@ -29,8 +35,6 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=builder /app/target/release/thoughts-backend .
|
COPY --from=builder /app/target/release/thoughts-backend .
|
||||||
|
|
||||||
COPY .env.example .env
|
|
||||||
|
|
||||||
RUN chown -R appuser:appgroup /app
|
RUN chown -R appuser:appgroup /app
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
@@ -18,7 +18,6 @@ bcrypt = "0.17.1"
|
|||||||
jsonwebtoken = "9.3.1"
|
jsonwebtoken = "9.3.1"
|
||||||
once_cell = "1.21.3"
|
once_cell = "1.21.3"
|
||||||
|
|
||||||
tokio = "1.45.1"
|
|
||||||
|
|
||||||
# db
|
# db
|
||||||
sea-orm = { workspace = true }
|
sea-orm = { workspace = true }
|
||||||
@@ -27,6 +26,7 @@ sea-orm = { workspace = true }
|
|||||||
utoipa = { workspace = true }
|
utoipa = { workspace = true }
|
||||||
|
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
# local dependencies
|
# local dependencies
|
||||||
app = { path = "../app" }
|
app = { path = "../app" }
|
||||||
@@ -38,6 +38,5 @@ tower-http = { version = "0.6.6", features = ["fs", "cors"] }
|
|||||||
tower-cookies = "0.11.0"
|
tower-cookies = "0.11.0"
|
||||||
anyhow = "1.0.98"
|
anyhow = "1.0.98"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
activitypub_federation = "0.6.5"
|
|
||||||
url = "2.5.7"
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
@@ -5,13 +5,14 @@ use axum::{
|
|||||||
|
|
||||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
use sea_orm::prelude::Uuid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use app::state::AppState;
|
use app::{persistence::api_key, state::AppState};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub sub: i32,
|
pub sub: Uuid,
|
||||||
pub exp: usize,
|
pub exp: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ static JWT_SECRET: Lazy<String> =
|
|||||||
Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set"));
|
Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set"));
|
||||||
|
|
||||||
pub struct AuthUser {
|
pub struct AuthUser {
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for AuthUser {
|
impl FromRequestParts<AppState> for AuthUser {
|
||||||
@@ -27,14 +28,24 @@ impl FromRequestParts<AppState> for AuthUser {
|
|||||||
|
|
||||||
async fn from_request_parts(
|
async fn from_request_parts(
|
||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
_state: &AppState,
|
state: &AppState,
|
||||||
) -> Result<Self, Self::Rejection> {
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
// --- Test User ID (Keep for testing) ---
|
||||||
if let Some(user_id_header) = parts.headers.get("x-test-user-id") {
|
if let Some(user_id_header) = parts.headers.get("x-test-user-id") {
|
||||||
let user_id_str = user_id_header.to_str().unwrap_or("0");
|
let user_id_str = user_id_header.to_str().unwrap_or("0");
|
||||||
let user_id = user_id_str.parse::<i32>().unwrap_or(0);
|
let user_id = user_id_str.parse::<Uuid>().unwrap_or(Uuid::nil());
|
||||||
return Ok(AuthUser { id: user_id });
|
return Ok(AuthUser { id: user_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- API Key Authentication ---
|
||||||
|
if let Some(api_key) = get_api_key_from_header(&parts.headers) {
|
||||||
|
return match api_key::validate_api_key(&state.conn, &api_key).await {
|
||||||
|
Ok(user) => Ok(AuthUser { id: user.id }),
|
||||||
|
Err(_) => Err((StatusCode::UNAUTHORIZED, "Invalid API Key")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JWT Authentication (Fallback) ---
|
||||||
let token = get_token_from_header(&parts.headers)
|
let token = get_token_from_header(&parts.headers)
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid token"))?;
|
.ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid token"))?;
|
||||||
|
|
||||||
@@ -55,3 +66,11 @@ fn get_token_from_header(headers: &HeaderMap) -> Option<String> {
|
|||||||
.and_then(|header| header.strip_prefix("Bearer "))
|
.and_then(|header| header.strip_prefix("Bearer "))
|
||||||
.map(|token| token.to_owned())
|
.map(|token| token.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_api_key_from_header(headers: &HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|header| header.to_str().ok())
|
||||||
|
.and_then(|header| header.strip_prefix("ApiKey "))
|
||||||
|
.map(|key| key.to_owned())
|
||||||
|
}
|
||||||
|
@@ -1,8 +1,10 @@
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod json;
|
mod json;
|
||||||
|
mod optional_auth;
|
||||||
mod valid;
|
mod valid;
|
||||||
|
|
||||||
pub use auth::AuthUser;
|
pub use auth::AuthUser;
|
||||||
pub use auth::Claims;
|
pub use auth::Claims;
|
||||||
pub use json::Json;
|
pub use json::Json;
|
||||||
|
pub use optional_auth::OptionalAuthUser;
|
||||||
pub use valid::Valid;
|
pub use valid::Valid;
|
||||||
|
21
thoughts-backend/api/src/extractor/optional_auth.rs
Normal file
21
thoughts-backend/api/src/extractor/optional_auth.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use super::AuthUser;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use app::state::AppState;
|
||||||
|
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||||
|
|
||||||
|
pub struct OptionalAuthUser(pub Option<AuthUser>);
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for OptionalAuthUser {
|
||||||
|
type Rejection = ApiError;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
match AuthUser::from_request_parts(parts, state).await {
|
||||||
|
Ok(user) => Ok(OptionalAuthUser(Some(user))),
|
||||||
|
// If the user is not authenticated for any reason, we just treat them as a guest.
|
||||||
|
Err(_) => Ok(OptionalAuthUser(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,70 +0,0 @@
|
|||||||
use app::{
|
|
||||||
persistence::{follow, user},
|
|
||||||
state::AppState,
|
|
||||||
};
|
|
||||||
use models::domains::thought;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
// This function handles pushing a new thought to all followers.
|
|
||||||
pub async fn federate_thought(
|
|
||||||
state: AppState,
|
|
||||||
thought: thought::Model,
|
|
||||||
author: models::domains::user::Model,
|
|
||||||
) {
|
|
||||||
// Find all followers of the author
|
|
||||||
let follower_ids = match follow::get_follower_ids(&state.conn, author.id).await {
|
|
||||||
Ok(ids) => ids,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to get followers for federation: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if follower_ids.is_empty() {
|
|
||||||
tracing::debug!("No followers to federate to for user {}", author.username);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let thought_url = format!("{}/thoughts/{}", &state.base_url, thought.id);
|
|
||||||
let author_url = format!("{}/users/{}", &state.base_url, author.username);
|
|
||||||
|
|
||||||
// Construct the "Create" activity containing the "Note" object
|
|
||||||
let activity = json!({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": format!("{}/activity", thought_url),
|
|
||||||
"type": "Create",
|
|
||||||
"actor": author_url,
|
|
||||||
"object": {
|
|
||||||
"id": thought_url,
|
|
||||||
"type": "Note",
|
|
||||||
"attributedTo": author_url,
|
|
||||||
"content": thought.content,
|
|
||||||
"published": thought.created_at.to_rfc3339(),
|
|
||||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
"cc": [format!("{}/followers", author_url)]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the inbox URLs for all followers
|
|
||||||
// In a real federated app, you would store remote users' full inbox URLs.
|
|
||||||
// For now, we assume followers are local and construct their inbox URLs.
|
|
||||||
let followers = match user::get_users_by_ids(&state.conn, follower_ids).await {
|
|
||||||
Ok(users) => users,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to get follower user objects: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
for follower in followers {
|
|
||||||
let inbox_url = format!("{}/users/{}/inbox", &state.base_url, follower.username);
|
|
||||||
tracing::info!("Federating post {} to {}", thought.id, inbox_url);
|
|
||||||
|
|
||||||
let res = client.post(&inbox_url).json(&activity).send().await;
|
|
||||||
|
|
||||||
if let Err(e) = res {
|
|
||||||
tracing::error!("Failed to federate to {}: {}", inbox_url, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -8,7 +8,6 @@ use app::state::AppState;
|
|||||||
|
|
||||||
use crate::routers::create_router;
|
use crate::routers::create_router;
|
||||||
|
|
||||||
// TODO: middleware, logging, authentication
|
|
||||||
pub fn setup_router(conn: DatabaseConnection, config: &Config) -> Router {
|
pub fn setup_router(conn: DatabaseConnection, config: &Config) -> Router {
|
||||||
create_router(AppState {
|
create_router(AppState {
|
||||||
conn,
|
conn,
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
mod error;
|
mod error;
|
||||||
mod extractor;
|
mod extractor;
|
||||||
mod federation;
|
|
||||||
mod init;
|
mod init;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
|
93
thoughts-backend/api/src/routers/api_key.rs
Normal file
93
thoughts-backend/api/src/routers/api_key.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
use crate::{
|
||||||
|
error::ApiError,
|
||||||
|
extractor::{AuthUser, Json},
|
||||||
|
models::ApiErrorResponse,
|
||||||
|
};
|
||||||
|
use app::{persistence::api_key, state::AppState};
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{delete, get},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use models::schemas::api_key::{ApiKeyListSchema, ApiKeyRequest, ApiKeyResponse};
|
||||||
|
use sea_orm::prelude::Uuid;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "List of API keys", body = ApiKeyListSchema),
|
||||||
|
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("bearerAuth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_keys(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let keys = api_key::get_api_keys_for_user(&state.conn, auth_user.id).await?;
|
||||||
|
Ok(Json(ApiKeyListSchema::from(keys)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "",
|
||||||
|
request_body = ApiKeyRequest,
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "API key created", body = ApiKeyResponse),
|
||||||
|
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||||
|
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||||
|
(status = 422, description = "Validation error", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("bearerAuth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn create_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Json(params): Json<ApiKeyRequest>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let (key_model, plaintext_key) =
|
||||||
|
api_key::create_api_key(&state.conn, auth_user.id, params.name).await?;
|
||||||
|
|
||||||
|
let response = ApiKeyResponse::from_parts(key_model, Some(plaintext_key));
|
||||||
|
Ok((StatusCode::CREATED, Json(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/{key_id}",
|
||||||
|
responses(
|
||||||
|
(status = 204, description = "API key deleted"),
|
||||||
|
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||||
|
(status = 404, description = "API key not found", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||||
|
),
|
||||||
|
params(
|
||||||
|
("key_id" = Uuid, Path, description = "The ID of the API key to delete")
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("bearerAuth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn delete_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(key_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
api_key::delete_api_key(&state.conn, key_id, auth_user.id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_api_key_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(get_keys).post(create_key))
|
||||||
|
.route("/{key_id}", delete(delete_key))
|
||||||
|
}
|
@@ -1,18 +1,27 @@
|
|||||||
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
|
||||||
use app::{
|
use app::{
|
||||||
persistence::{follow::get_followed_ids, thought::get_feed_for_user},
|
persistence::{follow::get_following_ids, thought::get_feed_for_users_and_self_paginated},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
use models::{
|
||||||
|
queries::pagination::PaginationQuery,
|
||||||
|
schemas::{pagination::PaginatedResponse, thought::ThoughtSchema},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{error::ApiError, extractor::AuthUser};
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "",
|
path = "",
|
||||||
|
params(PaginationQuery),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Authenticated user's feed", body = ThoughtListSchema)
|
(status = 200, description = "Authenticated user's feed", body = PaginatedResponse<ThoughtSchema>)
|
||||||
),
|
),
|
||||||
security(
|
security(
|
||||||
("api_key" = []),
|
("api_key" = []),
|
||||||
@@ -22,19 +31,35 @@ use crate::{error::ApiError, extractor::AuthUser};
|
|||||||
async fn feed_get(
|
async fn feed_get(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
Query(pagination): Query<PaginationQuery>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let followed_ids = get_followed_ids(&state.conn, auth_user.id).await?;
|
let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
|
||||||
let mut thoughts_with_authors = get_feed_for_user(&state.conn, followed_ids).await?;
|
let (thoughts_with_authors, total_items) = get_feed_for_users_and_self_paginated(
|
||||||
|
&state.conn,
|
||||||
let own_thoughts = get_feed_for_user(&state.conn, vec![auth_user.id]).await?;
|
auth_user.id,
|
||||||
thoughts_with_authors.extend(own_thoughts);
|
following_ids,
|
||||||
|
&pagination,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(ThoughtSchema::from)
|
.map(ThoughtSchema::from)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
|
let page = pagination.page();
|
||||||
|
let page_size = pagination.page_size();
|
||||||
|
let total_pages = (total_items as f64 / page_size as f64).ceil() as u64;
|
||||||
|
|
||||||
|
let response = PaginatedResponse {
|
||||||
|
items: thoughts_schema,
|
||||||
|
total_items,
|
||||||
|
total_pages,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_feed_router() -> Router<AppState> {
|
pub fn create_feed_router() -> Router<AppState> {
|
||||||
|
24
thoughts-backend/api/src/routers/friends.rs
Normal file
24
thoughts-backend/api/src/routers/friends.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
|
use app::{persistence::user, state::AppState};
|
||||||
|
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
|
||||||
|
use models::schemas::user::UserListSchema;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "List of authenticated user's friends", body = UserListSchema)
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = []))
|
||||||
|
)]
|
||||||
|
async fn get_friends_list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let friends = user::get_friends(&state.conn, auth_user.id).await?;
|
||||||
|
Ok(Json(UserListSchema::from(friends)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_friends_router() -> Router<AppState> {
|
||||||
|
Router::new().route("/", get(get_friends_list))
|
||||||
|
}
|
@@ -1,13 +1,16 @@
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
|
pub mod api_key;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
|
pub mod friends;
|
||||||
pub mod root;
|
pub mod root;
|
||||||
|
pub mod search;
|
||||||
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
pub mod well_known;
|
|
||||||
|
|
||||||
use crate::routers::{auth::create_auth_router, well_known::create_well_known_router};
|
use crate::routers::auth::create_auth_router;
|
||||||
use app::state::AppState;
|
use app::state::AppState;
|
||||||
use root::create_root_router;
|
use root::create_root_router;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
@@ -20,11 +23,13 @@ pub fn create_router(state: AppState) -> Router {
|
|||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(create_root_router())
|
.merge(create_root_router())
|
||||||
.nest("/.well-known", create_well_known_router())
|
|
||||||
.nest("/auth", create_auth_router())
|
.nest("/auth", create_auth_router())
|
||||||
.nest("/users", create_user_router())
|
.nest("/users", create_user_router())
|
||||||
.nest("/thoughts", create_thought_router())
|
.nest("/thoughts", create_thought_router())
|
||||||
.nest("/feed", create_feed_router())
|
.nest("/feed", create_feed_router())
|
||||||
|
.nest("/tags", tag::create_tag_router())
|
||||||
|
.nest("/friends", friends::create_friends_router())
|
||||||
|
.nest("/search", search::create_search_router())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
use axum::{extract::State, routing::get, Router};
|
use axum::{extract::State, http::StatusCode, routing::get, Router};
|
||||||
use sea_orm::{ConnectionTrait, Statement};
|
use sea_orm::{ConnectionTrait, Statement};
|
||||||
|
|
||||||
use app::state::AppState;
|
use app::state::AppState;
|
||||||
@@ -25,6 +25,12 @@ async fn root_get(state: State<AppState>) -> Result<String, ApiError> {
|
|||||||
result.unwrap().try_get_by(0).map_err(|e| e.into())
|
result.unwrap().try_get_by(0).map_err(|e| e.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_root_router() -> Router<AppState> {
|
async fn health_check() -> StatusCode {
|
||||||
Router::new().route("/", get(root_get))
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_root_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(root_get))
|
||||||
|
.route("/health", get(health_check))
|
||||||
}
|
}
|
||||||
|
53
thoughts-backend/api/src/routers/search.rs
Normal file
53
thoughts-backend/api/src/routers/search.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use crate::{error::ApiError, extractor::OptionalAuthUser};
|
||||||
|
use app::{persistence::search, state::AppState};
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use models::schemas::{
|
||||||
|
search::SearchResultsSchema,
|
||||||
|
thought::{ThoughtListSchema, ThoughtSchema},
|
||||||
|
user::UserListSchema,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use utoipa::IntoParams;
|
||||||
|
|
||||||
|
#[derive(Deserialize, IntoParams)]
|
||||||
|
pub struct SearchQuery {
|
||||||
|
q: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "",
|
||||||
|
params(SearchQuery),
|
||||||
|
responses((status = 200, body = SearchResultsSchema))
|
||||||
|
)]
|
||||||
|
async fn search_all(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
|
Query(query): Query<SearchQuery>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let viewer_id = viewer.0.map(|u| u.id);
|
||||||
|
|
||||||
|
let (users, thoughts) = tokio::try_join!(
|
||||||
|
search::search_users(&state.conn, &query.q),
|
||||||
|
search::search_thoughts(&state.conn, &query.q, viewer_id)
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let thought_schemas: Vec<ThoughtSchema> =
|
||||||
|
thoughts.into_iter().map(ThoughtSchema::from).collect();
|
||||||
|
|
||||||
|
let response = SearchResultsSchema {
|
||||||
|
users: UserListSchema::from(users),
|
||||||
|
thoughts: ThoughtListSchema::from(thought_schemas),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_search_router() -> Router<AppState> {
|
||||||
|
Router::new().route("/", get(search_all))
|
||||||
|
}
|
51
thoughts-backend/api/src/routers/tag.rs
Normal file
51
thoughts-backend/api/src/routers/tag.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use crate::{error::ApiError, extractor::OptionalAuthUser};
|
||||||
|
use app::{
|
||||||
|
persistence::{tag, thought::get_thoughts_by_tag_name},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "{tagName}",
|
||||||
|
params(("tagName" = String, Path, description = "Tag name")),
|
||||||
|
responses((status = 200, description = "List of thoughts with a specific tag", body = ThoughtListSchema))
|
||||||
|
)]
|
||||||
|
async fn get_thoughts_by_tag(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(tag_name): Path<String>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let thoughts_with_authors =
|
||||||
|
get_thoughts_by_tag_name(&state.conn, &tag_name, viewer.0.map(|u| u.id)).await;
|
||||||
|
let thoughts_with_authors = thoughts_with_authors?;
|
||||||
|
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||||
|
.into_iter()
|
||||||
|
.map(ThoughtSchema::from)
|
||||||
|
.collect();
|
||||||
|
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/popular",
|
||||||
|
responses((status = 200, description = "List of popular tags", body = Vec<String>))
|
||||||
|
)]
|
||||||
|
async fn get_popular_tags(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let tags = tag::get_popular_tags(&state.conn).await;
|
||||||
|
println!("Fetched popular tags: {:?}", tags);
|
||||||
|
let tags = tags?;
|
||||||
|
Ok(Json(tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_tag_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/{tag_name}", get(get_thoughts_by_tag))
|
||||||
|
.route("/popular", get(get_popular_tags))
|
||||||
|
}
|
@@ -2,7 +2,7 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{delete, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,15 +11,47 @@ use app::{
|
|||||||
persistence::thought::{create_thought, delete_thought, get_thought},
|
persistence::thought::{create_thought, delete_thought, get_thought},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
|
use models::{
|
||||||
|
params::thought::CreateThoughtParams,
|
||||||
|
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
|
||||||
|
};
|
||||||
|
use sea_orm::prelude::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
extractor::{AuthUser, Json, Valid},
|
extractor::{AuthUser, Json, OptionalAuthUser, Valid},
|
||||||
federation,
|
|
||||||
models::{ApiErrorResponse, ParamsErrorResponse},
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{id}",
|
||||||
|
params(
|
||||||
|
("id" = Uuid, Path, description = "Thought ID")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Thought found", body = ThoughtSchema),
|
||||||
|
(status = 404, description = "Not Found", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_thought_by_id(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let viewer_id = viewer.0.map(|u| u.id);
|
||||||
|
let thought = get_thought(&state.conn, id, viewer_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
let author = app::persistence::user::get_user(&state.conn, thought.author_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
let schema = ThoughtSchema::from_models(&thought, &author);
|
||||||
|
Ok(Json(schema))
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "",
|
path = "",
|
||||||
@@ -44,13 +76,6 @@ async fn thoughts_post(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(UserError::NotFound)?; // Should not happen if auth is valid
|
.ok_or(UserError::NotFound)?; // Should not happen if auth is valid
|
||||||
|
|
||||||
// Spawn a background task to handle federation without blocking the response
|
|
||||||
tokio::spawn(federation::federate_thought(
|
|
||||||
state.clone(),
|
|
||||||
thought.clone(),
|
|
||||||
author.clone(),
|
|
||||||
));
|
|
||||||
|
|
||||||
let schema = ThoughtSchema::from_models(&thought, &author);
|
let schema = ThoughtSchema::from_models(&thought, &author);
|
||||||
Ok((StatusCode::CREATED, Json(schema)))
|
Ok((StatusCode::CREATED, Json(schema)))
|
||||||
}
|
}
|
||||||
@@ -74,9 +99,9 @@ async fn thoughts_post(
|
|||||||
async fn thoughts_delete(
|
async fn thoughts_delete(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Path(id): Path<i32>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let thought = get_thought(&state.conn, id)
|
let thought = get_thought(&state.conn, id, Some(auth_user.id))
|
||||||
.await?
|
.await?
|
||||||
.ok_or(UserError::NotFound)?;
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
@@ -88,8 +113,33 @@ async fn thoughts_delete(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{id}/thread",
|
||||||
|
params(
|
||||||
|
("id" = Uuid, Path, description = "Thought ID")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Thought thread found", body = ThoughtThreadSchema),
|
||||||
|
(status = 404, description = "Not Found", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_thought_thread(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let viewer_id = viewer.0.map(|u| u.id);
|
||||||
|
let thread = app::persistence::thought::get_thought_with_replies(&state.conn, id, viewer_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
Ok(Json(thread))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_thought_router() -> Router<AppState> {
|
pub fn create_thought_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", post(thoughts_post))
|
.route("/", post(thoughts_post))
|
||||||
.route("/{id}", delete(thoughts_delete))
|
.route("/{id}/thread", get(get_thought_thread))
|
||||||
|
.route("/{id}", get(get_thought_by_id).delete(thoughts_delete))
|
||||||
}
|
}
|
||||||
|
@@ -5,22 +5,34 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
use sea_orm::prelude::Uuid;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use app::persistence::{
|
use app::persistence::{
|
||||||
follow,
|
follow,
|
||||||
thought::get_thoughts_by_user,
|
thought::get_thoughts_by_user,
|
||||||
user::{get_user, search_users},
|
user::{
|
||||||
|
get_all_users, get_followers, get_following, get_user, search_users, update_user_profile,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use app::state::AppState;
|
use app::state::AppState;
|
||||||
use app::{error::UserError, persistence::user::get_user_by_username};
|
use app::{error::UserError, persistence::user::get_user_by_username};
|
||||||
use models::schemas::thought::ThoughtListSchema;
|
use models::{
|
||||||
use models::schemas::user::{UserListSchema, UserSchema};
|
params::user::UpdateUserParams,
|
||||||
|
schemas::{pagination::PaginatedResponse, thought::ThoughtListSchema},
|
||||||
|
};
|
||||||
|
use models::{
|
||||||
|
queries::pagination::PaginationQuery,
|
||||||
|
schemas::user::{MeSchema, UserListSchema, UserSchema},
|
||||||
|
};
|
||||||
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
||||||
|
|
||||||
use crate::extractor::Json;
|
|
||||||
use crate::models::ApiErrorResponse;
|
|
||||||
use crate::{error::ApiError, extractor::AuthUser};
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
|
use crate::{extractor::OptionalAuthUser, models::ApiErrorResponse};
|
||||||
|
use crate::{
|
||||||
|
extractor::{Json, Valid},
|
||||||
|
routers::api_key::create_api_key_router,
|
||||||
|
};
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -59,12 +71,14 @@ async fn users_get(
|
|||||||
async fn user_thoughts_get(
|
async fn user_thoughts_get(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let user = get_user_by_username(&state.conn, &username)
|
let user = get_user_by_username(&state.conn, &username)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(UserError::NotFound)?;
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
let thoughts_with_authors = get_thoughts_by_user(&state.conn, user.id).await?;
|
let thoughts_with_authors =
|
||||||
|
get_thoughts_by_user(&state.conn, user.id, viewer.0.map(|u| u.id)).await?;
|
||||||
|
|
||||||
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -201,7 +215,7 @@ async fn get_user_by_param(
|
|||||||
Path(param): Path<String>,
|
Path(param): Path<String>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// First, try to handle it as a numeric ID.
|
// First, try to handle it as a numeric ID.
|
||||||
if let Ok(id) = param.parse::<i32>() {
|
if let Ok(id) = param.parse::<Uuid>() {
|
||||||
return match get_user(&state.conn, id).await {
|
return match get_user(&state.conn, id).await {
|
||||||
Ok(Some(user)) => Json(UserSchema::from(user)).into_response(),
|
Ok(Some(user)) => Json(UserSchema::from(user)).into_response(),
|
||||||
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
|
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
|
||||||
@@ -244,7 +258,12 @@ async fn get_user_by_param(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match get_user_by_username(&state.conn, &username).await {
|
match get_user_by_username(&state.conn, &username).await {
|
||||||
Ok(Some(user)) => Json(UserSchema::from(user)).into_response(),
|
Ok(Some(user)) => {
|
||||||
|
let top_friends = app::persistence::user::get_top_friends(&state.conn, user.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
Json(UserSchema::from((user, top_friends))).into_response()
|
||||||
|
}
|
||||||
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
|
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
|
||||||
Err(e) => ApiError::from(e).into_response(),
|
Err(e) => ApiError::from(e).into_response(),
|
||||||
}
|
}
|
||||||
@@ -263,12 +282,13 @@ async fn get_user_by_param(
|
|||||||
async fn user_outbox_get(
|
async fn user_outbox_get(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let user = get_user_by_username(&state.conn, &username)
|
let user = get_user_by_username(&state.conn, &username)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(UserError::NotFound)?;
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
let thoughts = get_thoughts_by_user(&state.conn, user.id).await?;
|
let thoughts = get_thoughts_by_user(&state.conn, user.id, viewer.0.map(|u| u.id)).await?;
|
||||||
|
|
||||||
// Format the outbox as an ActivityPub OrderedCollection
|
// Format the outbox as an ActivityPub OrderedCollection
|
||||||
let outbox_url = format!("{}/users/{}/outbox", &state.base_url, username);
|
let outbox_url = format!("{}/users/{}/outbox", &state.base_url, username);
|
||||||
@@ -311,11 +331,142 @@ async fn user_outbox_get(
|
|||||||
Ok((headers, Json(outbox)))
|
Ok((headers, Json(outbox)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/me",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Authenticated user's full profile", body = MeSchema)
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("bearer_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = get_user(&state.conn, auth_user.id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
let top_friends = app::persistence::user::get_top_friends(&state.conn, auth_user.id).await?;
|
||||||
|
|
||||||
|
let following = get_following(&state.conn, auth_user.id).await?;
|
||||||
|
|
||||||
|
let response = MeSchema {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
bio: user.bio,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
header_url: user.header_url,
|
||||||
|
custom_css: user.custom_css,
|
||||||
|
top_friends: top_friends.into_iter().map(|u| u.username).collect(),
|
||||||
|
joined_at: user.created_at.into(),
|
||||||
|
following: following.into_iter().map(UserSchema::from).collect(),
|
||||||
|
};
|
||||||
|
Ok(axum::Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/me",
|
||||||
|
request_body = UpdateUserParams,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Profile updated", body = UserSchema),
|
||||||
|
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||||
|
(status = 422, description = "Validation error", body = ApiErrorResponse)
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("bearer_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn update_me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Valid(Json(params)): Valid<Json<UpdateUserParams>>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let updated_user = update_user_profile(&state.conn, auth_user.id, params).await?;
|
||||||
|
Ok(axum::Json(UserSchema::from(updated_user)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{username}/following",
|
||||||
|
responses((status = 200, body = UserListSchema))
|
||||||
|
)]
|
||||||
|
async fn get_user_following(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = get_user_by_username(&state.conn, &username)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
let following_list = get_following(&state.conn, user.id).await?;
|
||||||
|
Ok(Json(UserListSchema::from(following_list)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{username}/followers",
|
||||||
|
responses((status = 200, body = UserListSchema))
|
||||||
|
)]
|
||||||
|
async fn get_user_followers(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = get_user_by_username(&state.conn, &username)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
let followers_list = get_followers(&state.conn, user.id).await?;
|
||||||
|
Ok(Json(UserListSchema::from(followers_list)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/all",
|
||||||
|
params(PaginationQuery),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "A public, paginated list of all users", body = PaginatedResponse<UserSchema>)
|
||||||
|
),
|
||||||
|
tag = "user"
|
||||||
|
)]
|
||||||
|
async fn get_all_users_public(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(pagination): Query<PaginationQuery>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let (users, total_items) = get_all_users(&state.conn, &pagination).await?;
|
||||||
|
|
||||||
|
let page = pagination.page();
|
||||||
|
let page_size = pagination.page_size();
|
||||||
|
let total_pages = (total_items as f64 / page_size as f64).ceil() as u64;
|
||||||
|
|
||||||
|
let response = PaginatedResponse {
|
||||||
|
items: users.into_iter().map(UserSchema::from).collect(),
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total_pages,
|
||||||
|
total_items,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_all_users_count(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let count = app::persistence::user::get_all_users_count(&state.conn).await?;
|
||||||
|
Ok(Json(json!({ "count": count })))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_user_router() -> Router<AppState> {
|
pub fn create_user_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(users_get))
|
.route("/", get(users_get))
|
||||||
|
.route("/all", get(get_all_users_public))
|
||||||
|
.route("/count", get(get_all_users_count))
|
||||||
|
.route("/me", get(get_me).put(update_me))
|
||||||
|
.nest("/me/api-keys", create_api_key_router())
|
||||||
.route("/{param}", get(get_user_by_param))
|
.route("/{param}", get(get_user_by_param))
|
||||||
.route("/{username}/thoughts", get(user_thoughts_get))
|
.route("/{username}/thoughts", get(user_thoughts_get))
|
||||||
|
.route("/{username}/followers", get(get_user_followers))
|
||||||
|
.route("/{username}/following", get(get_user_following))
|
||||||
.route(
|
.route(
|
||||||
"/{username}/follow",
|
"/{username}/follow",
|
||||||
post(user_follow_post).delete(user_follow_delete),
|
post(user_follow_post).delete(user_follow_delete),
|
||||||
|
@@ -1,70 +0,0 @@
|
|||||||
use app::state::AppState;
|
|
||||||
use axum::{
|
|
||||||
extract::{Query, State},
|
|
||||||
response::{IntoResponse, Json},
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct WebFingerQuery {
|
|
||||||
resource: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct WebFingerLink {
|
|
||||||
rel: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
type_: String,
|
|
||||||
href: Url,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct WebFingerResponse {
|
|
||||||
subject: String,
|
|
||||||
links: Vec<WebFingerLink>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn webfinger(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(query): Query<WebFingerQuery>,
|
|
||||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
|
||||||
if let Some((scheme, account_info)) = query.resource.split_once(':') {
|
|
||||||
if scheme != "acct" {
|
|
||||||
return Err((
|
|
||||||
axum::http::StatusCode::BAD_REQUEST,
|
|
||||||
"Invalid resource scheme",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let account_parts: Vec<&str> = account_info.split('@').collect();
|
|
||||||
let username = account_parts[0];
|
|
||||||
|
|
||||||
let user = match app::persistence::user::get_user_by_username(&state.conn, username).await {
|
|
||||||
Ok(Some(user)) => user,
|
|
||||||
_ => return Err((axum::http::StatusCode::NOT_FOUND, "User not found")),
|
|
||||||
};
|
|
||||||
|
|
||||||
let user_url = Url::parse(&format!("{}/users/{}", &state.base_url, user.username)).unwrap();
|
|
||||||
|
|
||||||
let response = WebFingerResponse {
|
|
||||||
subject: query.resource,
|
|
||||||
links: vec![WebFingerLink {
|
|
||||||
rel: "self".to_string(),
|
|
||||||
type_: "application/activity+json".to_string(),
|
|
||||||
href: user_url,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(response))
|
|
||||||
} else {
|
|
||||||
Err((
|
|
||||||
axum::http::StatusCode::BAD_REQUEST,
|
|
||||||
"Invalid resource format",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_well_known_router() -> axum::Router<AppState> {
|
|
||||||
axum::Router::new().route("/webfinger", axum::routing::get(webfinger))
|
|
||||||
}
|
|
@@ -12,5 +12,6 @@ path = "src/lib.rs"
|
|||||||
bcrypt = "0.17.1"
|
bcrypt = "0.17.1"
|
||||||
models = { path = "../models" }
|
models = { path = "../models" }
|
||||||
validator = "0.20"
|
validator = "0.20"
|
||||||
|
rand = "0.8.5"
|
||||||
sea-orm = { workspace = true }
|
sea-orm = { version = "1.1.12" }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
93
thoughts-backend/app/src/persistence/api_key.rs
Normal file
93
thoughts-backend/app/src/persistence/api_key.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||||
|
use models::domains::{api_key, user};
|
||||||
|
use rand::distributions::{Alphanumeric, DistString};
|
||||||
|
use sea_orm::{
|
||||||
|
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::UserError;
|
||||||
|
|
||||||
|
const KEY_PREFIX: &str = "th_";
|
||||||
|
const KEY_RANDOM_LENGTH: usize = 32;
|
||||||
|
const KEY_LOOKUP_PREFIX_LENGTH: usize = 8;
|
||||||
|
|
||||||
|
fn generate_key() -> String {
|
||||||
|
let random_part = Alphanumeric.sample_string(&mut rand::thread_rng(), KEY_RANDOM_LENGTH);
|
||||||
|
format!("{}{}", KEY_PREFIX, random_part)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_api_key(
|
||||||
|
db: &DbConn,
|
||||||
|
user_id: Uuid,
|
||||||
|
name: String,
|
||||||
|
) -> Result<(api_key::Model, String), UserError> {
|
||||||
|
let plaintext_key = generate_key();
|
||||||
|
let key_hash =
|
||||||
|
hash(&plaintext_key, DEFAULT_COST).map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
let key_prefix = plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH].to_string();
|
||||||
|
|
||||||
|
let new_key = api_key::ActiveModel {
|
||||||
|
user_id: Set(user_id),
|
||||||
|
name: Set(name),
|
||||||
|
key_hash: Set(key_hash),
|
||||||
|
key_prefix: Set(key_prefix),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok((new_key, plaintext_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn validate_api_key(db: &DbConn, plaintext_key: &str) -> Result<user::Model, UserError> {
|
||||||
|
if !plaintext_key.starts_with(KEY_PREFIX)
|
||||||
|
|| plaintext_key.len() != KEY_PREFIX.len() + KEY_RANDOM_LENGTH
|
||||||
|
{
|
||||||
|
return Err(UserError::Validation("Invalid API key format".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_prefix = &plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH];
|
||||||
|
|
||||||
|
let candidate_keys = api_key::Entity::find()
|
||||||
|
.filter(api_key::Column::KeyPrefix.eq(key_prefix))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
for key in candidate_keys {
|
||||||
|
if verify(plaintext_key, &key.key_hash).unwrap_or(false) {
|
||||||
|
return super::user::get_user(db, key.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?
|
||||||
|
.ok_or(UserError::NotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(UserError::Validation("Invalid API key".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_api_keys_for_user(
|
||||||
|
db: &DbConn,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<Vec<api_key::Model>, DbErr> {
|
||||||
|
api_key::Entity::find()
|
||||||
|
.filter(api_key::Column::UserId.eq(user_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_api_key(db: &DbConn, key_id: Uuid, user_id: Uuid) -> Result<(), UserError> {
|
||||||
|
let result = api_key::Entity::delete_many()
|
||||||
|
.filter(api_key::Column::Id.eq(key_id))
|
||||||
|
.filter(api_key::Column::UserId.eq(user_id)) // Ensure user owns the key
|
||||||
|
.exec(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected == 0 {
|
||||||
|
Err(UserError::NotFound)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
@@ -13,7 +13,6 @@ fn hash_password(password: &str) -> Result<String, BcryptError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::Model, UserError> {
|
pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::Model, UserError> {
|
||||||
// Validate the parameters
|
|
||||||
params
|
params
|
||||||
.validate()
|
.validate()
|
||||||
.map_err(|e| UserError::Validation(e.to_string()))?;
|
.map_err(|e| UserError::Validation(e.to_string()))?;
|
||||||
@@ -22,8 +21,10 @@ pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::
|
|||||||
hash_password(¶ms.password).map_err(|e| UserError::Internal(e.to_string()))?;
|
hash_password(¶ms.password).map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
let new_user = user::ActiveModel {
|
let new_user = user::ActiveModel {
|
||||||
username: Set(params.username),
|
username: Set(params.username.clone()),
|
||||||
password_hash: Set(Some(hashed_password)),
|
password_hash: Set(Some(hashed_password)),
|
||||||
|
email: Set(Some(params.email)),
|
||||||
|
display_name: Set(Some(params.username)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -1,11 +1,13 @@
|
|||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set};
|
use sea_orm::{
|
||||||
|
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{error::UserError, persistence::user::get_user_by_username};
|
use crate::{error::UserError, persistence::user::get_user_by_username};
|
||||||
use models::domains::follow;
|
use models::domains::follow;
|
||||||
|
|
||||||
pub async fn add_follower(
|
pub async fn add_follower(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
followed_id: i32,
|
following_id: Uuid,
|
||||||
follower_actor_id: &str,
|
follower_actor_id: &str,
|
||||||
) -> Result<(), UserError> {
|
) -> Result<(), UserError> {
|
||||||
let follower_username = follower_actor_id
|
let follower_username = follower_actor_id
|
||||||
@@ -18,21 +20,21 @@ pub async fn add_follower(
|
|||||||
.map_err(|e| UserError::Internal(e.to_string()))?
|
.map_err(|e| UserError::Internal(e.to_string()))?
|
||||||
.ok_or(UserError::NotFound)?;
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
follow_user(db, follower.id, followed_id)
|
follow_user(db, follower.id, following_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| UserError::Internal(e.to_string()))?;
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn follow_user(db: &DbConn, follower_id: i32, followed_id: i32) -> Result<(), DbErr> {
|
pub async fn follow_user(db: &DbConn, follower_id: Uuid, following_id: Uuid) -> Result<(), DbErr> {
|
||||||
if follower_id == followed_id {
|
if follower_id == following_id {
|
||||||
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
|
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let follow = follow::ActiveModel {
|
let follow = follow::ActiveModel {
|
||||||
follower_id: Set(follower_id),
|
follower_id: Set(follower_id),
|
||||||
followed_id: Set(followed_id),
|
following_id: Set(following_id),
|
||||||
};
|
};
|
||||||
|
|
||||||
follow.insert(db).await?;
|
follow.insert(db).await?;
|
||||||
@@ -41,12 +43,12 @@ pub async fn follow_user(db: &DbConn, follower_id: i32, followed_id: i32) -> Res
|
|||||||
|
|
||||||
pub async fn unfollow_user(
|
pub async fn unfollow_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
follower_id: i32,
|
follower_id: Uuid,
|
||||||
followed_id: i32,
|
following_id: Uuid,
|
||||||
) -> Result<(), UserError> {
|
) -> Result<(), UserError> {
|
||||||
let deleted_result = follow::Entity::delete_many()
|
let deleted_result = follow::Entity::delete_many()
|
||||||
.filter(follow::Column::FollowerId.eq(follower_id))
|
.filter(follow::Column::FollowerId.eq(follower_id))
|
||||||
.filter(follow::Column::FollowedId.eq(followed_id))
|
.filter(follow::Column::FollowingId.eq(following_id))
|
||||||
.exec(db)
|
.exec(db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| UserError::Internal(e.to_string()))?;
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
@@ -58,19 +60,32 @@ pub async fn unfollow_user(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_followed_ids(db: &DbConn, user_id: i32) -> Result<Vec<i32>, DbErr> {
|
pub async fn get_following_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
|
||||||
let followed_users = follow::Entity::find()
|
let followed_users = follow::Entity::find()
|
||||||
.filter(follow::Column::FollowerId.eq(user_id))
|
.filter(follow::Column::FollowerId.eq(user_id))
|
||||||
.all(db)
|
.all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(followed_users.into_iter().map(|f| f.followed_id).collect())
|
Ok(followed_users.into_iter().map(|f| f.following_id).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_follower_ids(db: &DbConn, user_id: i32) -> Result<Vec<i32>, DbErr> {
|
pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
|
||||||
let followers = follow::Entity::find()
|
let followers = follow::Entity::find()
|
||||||
.filter(follow::Column::FollowedId.eq(user_id))
|
.filter(follow::Column::FollowingId.eq(user_id))
|
||||||
.all(db)
|
.all(db)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(followers.into_iter().map(|f| f.follower_id).collect())
|
Ok(followers.into_iter().map(|f| f.follower_id).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_friend_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
|
||||||
|
let following = get_following_ids(db, user_id).await?;
|
||||||
|
let followers = get_follower_ids(db, user_id).await?;
|
||||||
|
|
||||||
|
let following_set: std::collections::HashSet<Uuid> = following.into_iter().collect();
|
||||||
|
let followers_set: std::collections::HashSet<Uuid> = followers.into_iter().collect();
|
||||||
|
|
||||||
|
Ok(following_set
|
||||||
|
.intersection(&followers_set)
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
@@ -1,4 +1,7 @@
|
|||||||
|
pub mod api_key;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod follow;
|
pub mod follow;
|
||||||
|
pub mod search;
|
||||||
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
66
thoughts-backend/app/src/persistence/search.rs
Normal file
66
thoughts-backend/app/src/persistence/search.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use models::{
|
||||||
|
domains::{thought, user},
|
||||||
|
schemas::thought::ThoughtWithAuthor,
|
||||||
|
};
|
||||||
|
use sea_orm::{
|
||||||
|
prelude::{Expr, Uuid},
|
||||||
|
DatabaseConnection, DbErr, EntityTrait, JoinType, QueryFilter, QuerySelect, RelationTrait,
|
||||||
|
Value,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::persistence::follow;
|
||||||
|
|
||||||
|
fn is_visible(
|
||||||
|
author_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
friend_ids: &[Uuid],
|
||||||
|
visibility: &thought::Visibility,
|
||||||
|
) -> bool {
|
||||||
|
match visibility {
|
||||||
|
thought::Visibility::Public => true,
|
||||||
|
thought::Visibility::Private => viewer_id.map_or(false, |v| v == author_id),
|
||||||
|
thought::Visibility::FriendsOnly => {
|
||||||
|
viewer_id.map_or(false, |v| v == author_id || friend_ids.contains(&author_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_thoughts(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
query: &str,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
||||||
|
let mut friend_ids = Vec::new();
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
friend_ids = follow::get_friend_ids(db, viewer).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We must join with the user table to get the author's username
|
||||||
|
let thoughts_with_authors = thought::Entity::find()
|
||||||
|
.column_as(user::Column::Username, "author_username")
|
||||||
|
.column_as(user::Column::DisplayName, "author_display_name")
|
||||||
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
|
.filter(Expr::cust_with_values(
|
||||||
|
"thought.search_document @@ websearch_to_tsquery('english', $1)",
|
||||||
|
[Value::from(query)],
|
||||||
|
))
|
||||||
|
.into_model::<ThoughtWithAuthor>() // Convert directly in the query
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Apply visibility filtering in Rust after the search
|
||||||
|
Ok(thoughts_with_authors
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| is_visible(t.author_id, viewer_id, &friend_ids, &t.visibility))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_users(db: &DatabaseConnection, query: &str) -> Result<Vec<user::Model>, DbErr> {
|
||||||
|
user::Entity::find()
|
||||||
|
.filter(Expr::cust_with_values(
|
||||||
|
"\"user\".search_document @@ websearch_to_tsquery('english', $1)",
|
||||||
|
[Value::from(query)],
|
||||||
|
))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
120
thoughts-backend/app/src/persistence/tag.rs
Normal file
120
thoughts-backend/app/src/persistence/tag.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use models::domains::{tag, thought, thought_tag};
|
||||||
|
use sea_orm::{
|
||||||
|
prelude::Expr, sea_query::Alias, sqlx::types::uuid, ColumnTrait, ConnectionTrait, DbErr,
|
||||||
|
EntityTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set,
|
||||||
|
};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
pub fn parse_hashtags(content: &str) -> Vec<String> {
|
||||||
|
content
|
||||||
|
.split_whitespace()
|
||||||
|
.filter_map(|word| {
|
||||||
|
if word.starts_with('#') && word.len() > 1 {
|
||||||
|
Some(word[1..].to_lowercase().to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_or_create_tags<C>(db: &C, names: Vec<String>) -> Result<Vec<tag::Model>, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
if names.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let existing_tags = tag::Entity::find()
|
||||||
|
.filter(tag::Column::Name.is_in(names.clone()))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let existing_names: HashSet<String> = existing_tags.iter().map(|t| t.name.clone()).collect();
|
||||||
|
let new_names: Vec<String> = names
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| !existing_names.contains(n))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !new_names.is_empty() {
|
||||||
|
let new_tags: Vec<tag::ActiveModel> = new_names
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| tag::ActiveModel {
|
||||||
|
name: Set(name),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
tag::Entity::insert_many(new_tags).exec(db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tag::Entity::find()
|
||||||
|
.filter(
|
||||||
|
tag::Column::Name.is_in(
|
||||||
|
existing_names
|
||||||
|
.union(&new_names.into_iter().collect())
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn link_tags_to_thought<C>(
|
||||||
|
db: &C,
|
||||||
|
thought_id: uuid::Uuid,
|
||||||
|
tags: Vec<tag::Model>,
|
||||||
|
) -> Result<(), DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
if tags.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let links: Vec<thought_tag::ActiveModel> = tags
|
||||||
|
.into_iter()
|
||||||
|
.map(|tag| thought_tag::ActiveModel {
|
||||||
|
thought_id: Set(thought_id),
|
||||||
|
tag_id: Set(tag.id),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
thought_tag::Entity::insert_many(links).exec(db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_popular_tags<C>(db: &C) -> Result<Vec<String>, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
let seven_days_ago = Utc::now() - Duration::days(7);
|
||||||
|
|
||||||
|
let popular_tags = tag::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(tag::Column::Name)
|
||||||
|
.column_as(Expr::col((tag::Entity, tag::Column::Id)).count(), "count")
|
||||||
|
.join(
|
||||||
|
sea_orm::JoinType::InnerJoin,
|
||||||
|
tag::Relation::ThoughtTag.def(),
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
sea_orm::JoinType::InnerJoin,
|
||||||
|
thought_tag::Relation::Thought.def(),
|
||||||
|
)
|
||||||
|
.filter(thought::Column::CreatedAt.gte(seven_days_ago))
|
||||||
|
.filter(thought::Column::Visibility.eq(thought::Visibility::Public))
|
||||||
|
.group_by(tag::Column::Name)
|
||||||
|
.group_by(tag::Column::Id)
|
||||||
|
.order_by_desc(Expr::col(Alias::new("count")))
|
||||||
|
.order_by_asc(tag::Column::Name)
|
||||||
|
.limit(10)
|
||||||
|
.into_tuple::<(String, i64)>()
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(popular_tags.into_iter().map(|(name, _)| name).collect())
|
||||||
|
}
|
@@ -1,51 +1,112 @@
|
|||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder,
|
prelude::Uuid, sea_query::SimpleExpr, ActiveModelTrait, ColumnTrait, Condition, DbConn, DbErr,
|
||||||
QuerySelect, RelationTrait, Set,
|
EntityTrait, JoinType, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait,
|
||||||
|
Set, TransactionTrait,
|
||||||
};
|
};
|
||||||
|
|
||||||
use models::{
|
use models::{
|
||||||
domains::{thought, user},
|
domains::{tag, thought, thought_tag, user},
|
||||||
params::thought::CreateThoughtParams,
|
params::thought::CreateThoughtParams,
|
||||||
schemas::thought::ThoughtWithAuthor,
|
queries::pagination::PaginationQuery,
|
||||||
|
schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::error::UserError;
|
use crate::{
|
||||||
|
error::UserError,
|
||||||
|
persistence::{
|
||||||
|
follow,
|
||||||
|
tag::{find_or_create_tags, link_tags_to_thought, parse_hashtags},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
pub async fn create_thought(
|
pub async fn create_thought(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
author_id: i32,
|
author_id: Uuid,
|
||||||
params: CreateThoughtParams,
|
params: CreateThoughtParams,
|
||||||
) -> Result<thought::Model, DbErr> {
|
) -> Result<thought::Model, DbErr> {
|
||||||
thought::ActiveModel {
|
let txn = db.begin().await?;
|
||||||
|
|
||||||
|
let new_thought = thought::ActiveModel {
|
||||||
author_id: Set(author_id),
|
author_id: Set(author_id),
|
||||||
content: Set(params.content),
|
content: Set(params.content.clone()),
|
||||||
|
reply_to_id: Set(params.reply_to_id),
|
||||||
|
visibility: Set(params.visibility.unwrap_or(thought::Visibility::Public)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.insert(db)
|
.insert(&txn)
|
||||||
.await
|
.await?;
|
||||||
|
|
||||||
|
if new_thought.visibility == thought::Visibility::Public {
|
||||||
|
let tag_names = parse_hashtags(¶ms.content);
|
||||||
|
if !tag_names.is_empty() {
|
||||||
|
let tags = find_or_create_tags(&txn, tag_names).await?;
|
||||||
|
link_tags_to_thought(&txn, new_thought.id, tags).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
txn.commit().await?;
|
||||||
|
Ok(new_thought)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_thought(db: &DbConn, thought_id: i32) -> Result<Option<thought::Model>, DbErr> {
|
pub async fn get_thought(
|
||||||
thought::Entity::find_by_id(thought_id).one(db).await
|
db: &DbConn,
|
||||||
|
thought_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
) -> Result<Option<thought::Model>, DbErr> {
|
||||||
|
let thought = thought::Entity::find_by_id(thought_id).one(db).await?;
|
||||||
|
|
||||||
|
match thought {
|
||||||
|
Some(t) => {
|
||||||
|
if t.visibility == thought::Visibility::Public {
|
||||||
|
return Ok(Some(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
if t.author_id == viewer {
|
||||||
|
return Ok(Some(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.visibility == thought::Visibility::FriendsOnly {
|
||||||
|
let author_friends = follow::get_friend_ids(db, t.author_id).await?;
|
||||||
|
if author_friends.contains(&viewer) {
|
||||||
|
return Ok(Some(t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_thought(db: &DbConn, thought_id: i32) -> Result<(), DbErr> {
|
pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> Result<(), DbErr> {
|
||||||
thought::Entity::delete_by_id(thought_id).exec(db).await?;
|
thought::Entity::delete_by_id(thought_id).exec(db).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_thoughts_by_user(
|
pub async fn get_thoughts_by_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
user_id: i32,
|
user_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
||||||
|
let mut friend_ids = vec![];
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
friend_ids = follow::get_friend_ids(db, viewer).await?;
|
||||||
|
}
|
||||||
|
|
||||||
thought::Entity::find()
|
thought::Entity::find()
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(thought::Column::Id)
|
.column(thought::Column::Id)
|
||||||
.column(thought::Column::Content)
|
.column(thought::Column::Content)
|
||||||
|
.column(thought::Column::ReplyToId)
|
||||||
.column(thought::Column::CreatedAt)
|
.column(thought::Column::CreatedAt)
|
||||||
.column(thought::Column::AuthorId)
|
.column(thought::Column::AuthorId)
|
||||||
|
.column(thought::Column::Visibility)
|
||||||
|
.column_as(user::Column::DisplayName, "author_display_name")
|
||||||
.column_as(user::Column::Username, "author_username")
|
.column_as(user::Column::Username, "author_username")
|
||||||
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
|
.filter(apply_visibility_filter(user_id, viewer_id, &friend_ids))
|
||||||
.filter(thought::Column::AuthorId.eq(user_id))
|
.filter(thought::Column::AuthorId.eq(user_id))
|
||||||
.order_by_desc(thought::Column::CreatedAt)
|
.order_by_desc(thought::Column::CreatedAt)
|
||||||
.into_model::<ThoughtWithAuthor>()
|
.into_model::<ThoughtWithAuthor>()
|
||||||
@@ -55,24 +116,271 @@ pub async fn get_thoughts_by_user(
|
|||||||
|
|
||||||
pub async fn get_feed_for_user(
|
pub async fn get_feed_for_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
followed_ids: Vec<i32>,
|
following_ids: Vec<Uuid>,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
|
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
|
||||||
if followed_ids.is_empty() {
|
if following_ids.is_empty() {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut friend_ids = vec![];
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
friend_ids = follow::get_friend_ids(db, viewer)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
thought::Entity::find()
|
thought::Entity::find()
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(thought::Column::Id)
|
.column(thought::Column::Id)
|
||||||
.column(thought::Column::Content)
|
.column(thought::Column::Content)
|
||||||
|
.column(thought::Column::ReplyToId)
|
||||||
.column(thought::Column::CreatedAt)
|
.column(thought::Column::CreatedAt)
|
||||||
|
.column(thought::Column::Visibility)
|
||||||
.column(thought::Column::AuthorId)
|
.column(thought::Column::AuthorId)
|
||||||
.column_as(user::Column::Username, "author_username")
|
.column_as(user::Column::Username, "author_username")
|
||||||
|
.column_as(user::Column::DisplayName, "author_display_name")
|
||||||
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
.filter(thought::Column::AuthorId.is_in(followed_ids))
|
.filter(
|
||||||
|
Condition::any().add(following_ids.iter().fold(
|
||||||
|
Condition::all(),
|
||||||
|
|cond, &author_id| {
|
||||||
|
cond.add(apply_visibility_filter(author_id, viewer_id, &friend_ids))
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.filter(thought::Column::AuthorId.is_in(following_ids))
|
||||||
.order_by_desc(thought::Column::CreatedAt)
|
.order_by_desc(thought::Column::CreatedAt)
|
||||||
.into_model::<ThoughtWithAuthor>()
|
.into_model::<ThoughtWithAuthor>()
|
||||||
.all(db)
|
.all(db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| UserError::Internal(e.to_string()))
|
.map_err(|e| UserError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_feed_for_users_and_self(
|
||||||
|
db: &DbConn,
|
||||||
|
user_id: Uuid,
|
||||||
|
following_ids: Vec<Uuid>,
|
||||||
|
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
||||||
|
let mut authors_to_include = following_ids;
|
||||||
|
authors_to_include.push(user_id);
|
||||||
|
|
||||||
|
thought::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(thought::Column::Id)
|
||||||
|
.column(thought::Column::Content)
|
||||||
|
.column(thought::Column::ReplyToId)
|
||||||
|
.column(thought::Column::CreatedAt)
|
||||||
|
.column(thought::Column::Visibility)
|
||||||
|
.column(thought::Column::AuthorId)
|
||||||
|
.column_as(user::Column::Username, "author_username")
|
||||||
|
.column_as(user::Column::DisplayName, "author_display_name")
|
||||||
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
|
.filter(thought::Column::AuthorId.is_in(authors_to_include))
|
||||||
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(thought::Column::Visibility.eq(thought::Visibility::Public))
|
||||||
|
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)),
|
||||||
|
)
|
||||||
|
.order_by_desc(thought::Column::CreatedAt)
|
||||||
|
.into_model::<ThoughtWithAuthor>()
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_feed_for_users_and_self_paginated(
|
||||||
|
db: &DbConn,
|
||||||
|
user_id: Uuid,
|
||||||
|
following_ids: Vec<Uuid>,
|
||||||
|
pagination: &PaginationQuery,
|
||||||
|
) -> Result<(Vec<ThoughtWithAuthor>, u64), DbErr> {
|
||||||
|
let mut authors_to_include = following_ids;
|
||||||
|
authors_to_include.push(user_id);
|
||||||
|
|
||||||
|
let paginator = thought::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(thought::Column::Id)
|
||||||
|
.column(thought::Column::Content)
|
||||||
|
.column(thought::Column::ReplyToId)
|
||||||
|
.column(thought::Column::CreatedAt)
|
||||||
|
.column(thought::Column::Visibility)
|
||||||
|
.column(thought::Column::AuthorId)
|
||||||
|
.column_as(user::Column::Username, "author_username")
|
||||||
|
.column_as(user::Column::DisplayName, "author_display_name")
|
||||||
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
|
.filter(thought::Column::AuthorId.is_in(authors_to_include))
|
||||||
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(thought::Column::Visibility.eq(thought::Visibility::Public))
|
||||||
|
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)),
|
||||||
|
)
|
||||||
|
.order_by_desc(thought::Column::CreatedAt)
|
||||||
|
.into_model::<ThoughtWithAuthor>()
|
||||||
|
.paginate(db, pagination.page_size());
|
||||||
|
|
||||||
|
let total_items = paginator.num_items().await?;
|
||||||
|
let thoughts = paginator.fetch_page(pagination.page() - 1).await?;
|
||||||
|
|
||||||
|
Ok((thoughts, total_items))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_thoughts_by_tag_name(
|
||||||
|
db: &DbConn,
|
||||||
|
tag_name: &str,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
||||||
|
let mut friend_ids = Vec::new();
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
friend_ids = follow::get_friend_ids(db, viewer).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let thoughts = thought::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(thought::Column::Id)
|
||||||
|
.column(thought::Column::Content)
|
||||||
|
.column(thought::Column::ReplyToId)
|
||||||
|
.column(thought::Column::CreatedAt)
|
||||||
|
.column(thought::Column::AuthorId)
|
||||||
|
.column(thought::Column::Visibility)
|
||||||
|
.column_as(user::Column::Username, "author_username")
|
||||||
|
.column_as(user::Column::DisplayName, "author_display_name")
|
||||||
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
|
.join(JoinType::InnerJoin, thought::Relation::ThoughtTag.def())
|
||||||
|
.join(JoinType::InnerJoin, thought_tag::Relation::Tag.def())
|
||||||
|
.filter(tag::Column::Name.eq(tag_name.to_lowercase()))
|
||||||
|
.order_by_desc(thought::Column::CreatedAt)
|
||||||
|
.into_model::<ThoughtWithAuthor>()
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let visible_thoughts = thoughts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|thought| {
|
||||||
|
let mut condition = thought.visibility == thought::Visibility::Public;
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
if thought.author_id == viewer {
|
||||||
|
condition = true;
|
||||||
|
}
|
||||||
|
if thought.visibility == thought::Visibility::FriendsOnly
|
||||||
|
&& friend_ids.contains(&thought.author_id)
|
||||||
|
{
|
||||||
|
condition = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
condition
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(visible_thoughts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_visibility_filter(
|
||||||
|
user_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
friend_ids: &[Uuid],
|
||||||
|
) -> SimpleExpr {
|
||||||
|
let mut condition =
|
||||||
|
Condition::any().add(thought::Column::Visibility.eq(thought::Visibility::Public));
|
||||||
|
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
if user_id == viewer {
|
||||||
|
condition = condition
|
||||||
|
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly))
|
||||||
|
.add(thought::Column::Visibility.eq(thought::Visibility::Private));
|
||||||
|
} else if !friend_ids.is_empty() && friend_ids.contains(&user_id) {
|
||||||
|
condition =
|
||||||
|
condition.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
condition.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_thought_with_replies(
|
||||||
|
db: &DbConn,
|
||||||
|
thought_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
) -> Result<Option<ThoughtThreadSchema>, DbErr> {
|
||||||
|
let root_thought = match get_thought(db, thought_id, viewer_id).await? {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut all_thoughts_in_thread = vec![root_thought.clone()];
|
||||||
|
let mut ids_to_fetch = vec![root_thought.id];
|
||||||
|
let mut friend_ids = vec![];
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
friend_ids = follow::get_friend_ids(db, viewer).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
while !ids_to_fetch.is_empty() {
|
||||||
|
let replies = thought::Entity::find()
|
||||||
|
.filter(thought::Column::ReplyToId.is_in(ids_to_fetch))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if replies.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ids_to_fetch = replies.iter().map(|r| r.id).collect();
|
||||||
|
all_thoughts_in_thread.extend(replies);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut thought_schemas = vec![];
|
||||||
|
for thought in all_thoughts_in_thread {
|
||||||
|
if let Some(author) = user::Entity::find_by_id(thought.author_id).one(db).await? {
|
||||||
|
let is_visible = match thought.visibility {
|
||||||
|
thought::Visibility::Public => true,
|
||||||
|
thought::Visibility::Private => viewer_id.map_or(false, |v| v == thought.author_id),
|
||||||
|
thought::Visibility::FriendsOnly => viewer_id.map_or(false, |v| {
|
||||||
|
v == thought.author_id || friend_ids.contains(&thought.author_id)
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_visible {
|
||||||
|
thought_schemas.push(ThoughtSchema::from_models(&thought, &author));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_thread(
|
||||||
|
thought_id: Uuid,
|
||||||
|
schemas_map: &std::collections::HashMap<Uuid, ThoughtSchema>,
|
||||||
|
replies_map: &std::collections::HashMap<Uuid, Vec<Uuid>>,
|
||||||
|
) -> Option<ThoughtThreadSchema> {
|
||||||
|
schemas_map.get(&thought_id).map(|thought_schema| {
|
||||||
|
let replies = replies_map
|
||||||
|
.get(&thought_id)
|
||||||
|
.unwrap_or(&vec![])
|
||||||
|
.iter()
|
||||||
|
.filter_map(|reply_id| build_thread(*reply_id, schemas_map, replies_map))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ThoughtThreadSchema {
|
||||||
|
id: thought_schema.id,
|
||||||
|
author_username: thought_schema.author_username.clone(),
|
||||||
|
author_display_name: thought_schema.author_display_name.clone(),
|
||||||
|
content: thought_schema.content.clone(),
|
||||||
|
visibility: thought_schema.visibility.clone(),
|
||||||
|
reply_to_id: thought_schema.reply_to_id,
|
||||||
|
created_at: thought_schema.created_at.clone(),
|
||||||
|
replies,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let schemas_map: std::collections::HashMap<Uuid, ThoughtSchema> =
|
||||||
|
thought_schemas.into_iter().map(|s| (s.id, s)).collect();
|
||||||
|
|
||||||
|
let mut replies_map: std::collections::HashMap<Uuid, Vec<Uuid>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for thought in schemas_map.values() {
|
||||||
|
if let Some(parent_id) = thought.reply_to_id {
|
||||||
|
if schemas_map.contains_key(&parent_id) {
|
||||||
|
replies_map.entry(parent_id).or_default().push(thought.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(build_thread(root_thought.id, &schemas_map, &replies_map))
|
||||||
|
}
|
||||||
|
@@ -1,9 +1,17 @@
|
|||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set};
|
use models::queries::pagination::PaginationQuery;
|
||||||
|
use sea_orm::prelude::Uuid;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, PaginatorTrait,
|
||||||
|
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait,
|
||||||
|
};
|
||||||
|
|
||||||
use models::domains::user;
|
use models::domains::{top_friends, user};
|
||||||
use models::params::user::CreateUserParams;
|
use models::params::user::{CreateUserParams, UpdateUserParams};
|
||||||
use models::queries::user::UserQuery;
|
use models::queries::user::UserQuery;
|
||||||
|
|
||||||
|
use crate::error::UserError;
|
||||||
|
use crate::persistence::follow::{get_follower_ids, get_following_ids, get_friend_ids};
|
||||||
|
|
||||||
pub async fn create_user(
|
pub async fn create_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
params: CreateUserParams,
|
params: CreateUserParams,
|
||||||
@@ -23,7 +31,7 @@ pub async fn search_users(db: &DbConn, query: UserQuery) -> Result<Vec<user::Mod
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user(db: &DbConn, id: i32) -> Result<Option<user::Model>, DbErr> {
|
pub async fn get_user(db: &DbConn, id: Uuid) -> Result<Option<user::Model>, DbErr> {
|
||||||
user::Entity::find_by_id(id).one(db).await
|
user::Entity::find_by_id(id).one(db).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,9 +45,142 @@ pub async fn get_user_by_username(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_users_by_ids(db: &DbConn, ids: Vec<i32>) -> Result<Vec<user::Model>, DbErr> {
|
pub async fn get_users_by_ids(db: &DbConn, ids: Vec<Uuid>) -> Result<Vec<user::Model>, DbErr> {
|
||||||
user::Entity::find()
|
user::Entity::find()
|
||||||
.filter(user::Column::Id.is_in(ids))
|
.filter(user::Column::Id.is_in(ids))
|
||||||
.all(db)
|
.all(db)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_user_profile(
|
||||||
|
db: &DbConn,
|
||||||
|
user_id: Uuid,
|
||||||
|
params: UpdateUserParams,
|
||||||
|
) -> Result<user::Model, UserError> {
|
||||||
|
let mut user: user::ActiveModel = get_user(db, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?
|
||||||
|
.ok_or(UserError::NotFound)?
|
||||||
|
.into();
|
||||||
|
|
||||||
|
if let Some(display_name) = params.display_name {
|
||||||
|
user.display_name = Set(Some(display_name));
|
||||||
|
}
|
||||||
|
if let Some(bio) = params.bio {
|
||||||
|
user.bio = Set(Some(bio));
|
||||||
|
}
|
||||||
|
if let Some(avatar_url) = params.avatar_url {
|
||||||
|
user.avatar_url = Set(Some(avatar_url));
|
||||||
|
}
|
||||||
|
if let Some(header_url) = params.header_url {
|
||||||
|
user.header_url = Set(Some(header_url));
|
||||||
|
}
|
||||||
|
if let Some(custom_css) = params.custom_css {
|
||||||
|
user.custom_css = Set(Some(custom_css));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(friend_usernames) = params.top_friends {
|
||||||
|
let txn = db
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
top_friends::Entity::delete_many()
|
||||||
|
.filter(top_friends::Column::UserId.eq(user_id))
|
||||||
|
.exec(&txn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let friends = user::Entity::find()
|
||||||
|
.filter(user::Column::Username.is_in(friend_usernames.clone()))
|
||||||
|
.all(&txn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
if friends.len() != friend_usernames.len() {
|
||||||
|
return Err(UserError::Validation(
|
||||||
|
"One or more usernames in top_friends do not exist".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_top_friends: Vec<top_friends::ActiveModel> = friends
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, friend)| top_friends::ActiveModel {
|
||||||
|
user_id: Set(user_id),
|
||||||
|
friend_id: Set(friend.id),
|
||||||
|
position: Set((index + 1) as i16),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !new_top_friends.is_empty() {
|
||||||
|
top_friends::Entity::insert_many(new_top_friends)
|
||||||
|
.exec(&txn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
txn.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_top_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
||||||
|
user::Entity::find()
|
||||||
|
.join(
|
||||||
|
JoinType::InnerJoin,
|
||||||
|
top_friends::Relation::Friend.def().rev(),
|
||||||
|
)
|
||||||
|
.filter(top_friends::Column::UserId.eq(user_id))
|
||||||
|
.order_by_asc(top_friends::Column::Position)
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
||||||
|
let friend_ids = get_friend_ids(db, user_id).await?;
|
||||||
|
if friend_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
get_users_by_ids(db, friend_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_following(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
||||||
|
let following_ids = get_following_ids(db, user_id).await?;
|
||||||
|
if following_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
get_users_by_ids(db, following_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_followers(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
||||||
|
let follower_ids = get_follower_ids(db, user_id).await?;
|
||||||
|
if follower_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
get_users_by_ids(db, follower_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_users(
|
||||||
|
db: &DbConn,
|
||||||
|
pagination: &PaginationQuery,
|
||||||
|
) -> Result<(Vec<user::Model>, u64), DbErr> {
|
||||||
|
let paginator = user::Entity::find()
|
||||||
|
.order_by_desc(user::Column::CreatedAt)
|
||||||
|
.paginate(db, pagination.page_size());
|
||||||
|
|
||||||
|
let total_items = paginator.num_items().await?;
|
||||||
|
let users = paginator.fetch_page(pagination.page() - 1).await?;
|
||||||
|
|
||||||
|
Ok((users, total_items))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_users_count(db: &DbConn) -> Result<u64, DbErr> {
|
||||||
|
user::Entity::find().count(db).await
|
||||||
|
}
|
||||||
|
@@ -3,6 +3,10 @@ name = "common"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "common"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
utoipa = { workspace = true }
|
utoipa = { workspace = true }
|
||||||
|
@@ -5,7 +5,7 @@ use sea_query::ValueTypeErr;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema, Debug)]
|
#[derive(Serialize, ToSchema, Debug, Clone)]
|
||||||
#[schema(example = "2025-09-05T12:34:56Z")]
|
#[schema(example = "2025-09-05T12:34:56Z")]
|
||||||
pub struct DateTimeWithTimeZoneWrapper(String);
|
pub struct DateTimeWithTimeZoneWrapper(String);
|
||||||
|
|
||||||
|
@@ -10,6 +10,7 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
utoipa = { workspace = true, features = ["axum_extras"] }
|
utoipa = { workspace = true, features = ["axum_extras"] }
|
||||||
utoipa-swagger-ui = { version = "9.0.2", features = [
|
utoipa-swagger-ui = { version = "9.0.2", features = [
|
||||||
"axum",
|
"axum",
|
||||||
@@ -19,5 +20,5 @@ utoipa-scalar = { version = "0.3.0", features = [
|
|||||||
"axum",
|
"axum",
|
||||||
], default-features = false }
|
], default-features = false }
|
||||||
|
|
||||||
api = { path = "../api" }
|
# api = { path = "../api" }
|
||||||
models = { path = "../models" }
|
models = { path = "../models" }
|
||||||
|
16
thoughts-backend/doc/src/api_key.rs
Normal file
16
thoughts-backend/doc/src/api_key.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use api::{models::ApiErrorResponse, routers::api_key::*};
|
||||||
|
use models::schemas::api_key::{ApiKeyListSchema, ApiKeyRequest, ApiKeyResponse, ApiKeySchema};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(get_keys, create_key, delete_key),
|
||||||
|
components(schemas(
|
||||||
|
ApiKeySchema,
|
||||||
|
ApiKeyListSchema,
|
||||||
|
ApiKeyRequest,
|
||||||
|
ApiKeyResponse,
|
||||||
|
ApiErrorResponse,
|
||||||
|
))
|
||||||
|
)]
|
||||||
|
pub(super) struct ApiKeyApi;
|
12
thoughts-backend/doc/src/friends.rs
Normal file
12
thoughts-backend/doc/src/friends.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
use api::models::{ApiErrorResponse, ParamsErrorResponse};
|
||||||
|
use api::routers::friends::*;
|
||||||
|
use models::schemas::user::{UserListSchema, UserSchema};
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(get_friends_list,),
|
||||||
|
components(schemas(UserListSchema, ApiErrorResponse, ParamsErrorResponse, UserSchema))
|
||||||
|
)]
|
||||||
|
pub(super) struct FriendsApi;
|
@@ -6,27 +6,17 @@ use utoipa::{
|
|||||||
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
||||||
use utoipa_swagger_ui::SwaggerUi;
|
use utoipa_swagger_ui::SwaggerUi;
|
||||||
|
|
||||||
mod auth;
|
|
||||||
mod feed;
|
|
||||||
mod root;
|
|
||||||
mod thought;
|
|
||||||
mod user;
|
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
nest(
|
|
||||||
(path = "/", api = root::RootApi),
|
|
||||||
(path = "/auth", api = auth::AuthApi),
|
|
||||||
(path = "/users", api = user::UserApi),
|
|
||||||
(path = "/thoughts", api = thought::ThoughtApi),
|
|
||||||
(path = "/feed", api = feed::FeedApi),
|
|
||||||
),
|
|
||||||
tags(
|
tags(
|
||||||
(name = "root", description = "Root API"),
|
(name = "root", description = "Root API"),
|
||||||
(name = "auth", description = "Authentication API"),
|
(name = "auth", description = "Authentication API"),
|
||||||
(name = "user", description = "User & Social API"),
|
(name = "user", description = "User & Social API"),
|
||||||
(name = "thought", description = "Thoughts API"),
|
(name = "thought", description = "Thoughts API"),
|
||||||
(name = "feed", description = "Feed API"),
|
(name = "feed", description = "Feed API"),
|
||||||
|
(name = "tag", description = "Tag Discovery API"),
|
||||||
|
(name = "friends", description = "Friends API"),
|
||||||
|
(name = "search", description = "Search API"),
|
||||||
),
|
),
|
||||||
modifiers(&SecurityAddon),
|
modifiers(&SecurityAddon),
|
||||||
)]
|
)]
|
||||||
@@ -47,12 +37,14 @@ impl Modify for SecurityAddon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait ApiDoc {
|
pub trait ApiDocExt {
|
||||||
fn attach_doc(self) -> Self;
|
fn attach_doc(self) -> Self;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiDoc for Router {
|
impl ApiDocExt for Router {
|
||||||
fn attach_doc(self) -> Self {
|
fn attach_doc(self) -> Self {
|
||||||
|
tracing::info!("Attaching API documentation");
|
||||||
|
|
||||||
self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi()))
|
self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi()))
|
||||||
.merge(Scalar::with_url("/scalar", _ApiDoc::openapi()))
|
.merge(Scalar::with_url("/scalar", _ApiDoc::openapi()))
|
||||||
}
|
}
|
||||||
|
21
thoughts-backend/doc/src/search.rs
Normal file
21
thoughts-backend/doc/src/search.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use api::{models::ApiErrorResponse, routers::search::*};
|
||||||
|
use models::schemas::{
|
||||||
|
search::SearchResultsSchema,
|
||||||
|
thought::{ThoughtListSchema, ThoughtSchema},
|
||||||
|
user::{UserListSchema, UserSchema},
|
||||||
|
};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(search_all),
|
||||||
|
components(schemas(
|
||||||
|
SearchResultsSchema,
|
||||||
|
ApiErrorResponse,
|
||||||
|
ThoughtSchema,
|
||||||
|
ThoughtListSchema,
|
||||||
|
UserSchema,
|
||||||
|
UserListSchema
|
||||||
|
))
|
||||||
|
)]
|
||||||
|
pub(super) struct SearchApi;
|
12
thoughts-backend/doc/src/tag.rs
Normal file
12
thoughts-backend/doc/src/tag.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// in thoughts-backend/doc/src/tag.rs
|
||||||
|
|
||||||
|
use api::{models::ApiErrorResponse, routers::tag::*};
|
||||||
|
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(get_thoughts_by_tag, get_popular_tags),
|
||||||
|
components(schemas(ThoughtSchema, ThoughtListSchema, ApiErrorResponse))
|
||||||
|
)]
|
||||||
|
pub(super) struct TagApi;
|
@@ -2,15 +2,19 @@ use api::{
|
|||||||
models::{ApiErrorResponse, ParamsErrorResponse},
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
routers::thought::*,
|
routers::thought::*,
|
||||||
};
|
};
|
||||||
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
|
use models::{
|
||||||
|
params::thought::CreateThoughtParams,
|
||||||
|
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
|
||||||
|
};
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(thoughts_post, thoughts_delete),
|
paths(thoughts_post, thoughts_delete, get_thought_by_id, get_thought_thread),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
CreateThoughtParams,
|
CreateThoughtParams,
|
||||||
ThoughtSchema,
|
ThoughtSchema,
|
||||||
|
ThoughtThreadSchema,
|
||||||
ApiErrorResponse,
|
ApiErrorResponse,
|
||||||
ParamsErrorResponse
|
ParamsErrorResponse
|
||||||
))
|
))
|
||||||
|
@@ -2,7 +2,7 @@ use utoipa::OpenApi;
|
|||||||
|
|
||||||
use api::models::{ApiErrorResponse, ParamsErrorResponse};
|
use api::models::{ApiErrorResponse, ParamsErrorResponse};
|
||||||
use api::routers::user::*;
|
use api::routers::user::*;
|
||||||
use models::params::user::CreateUserParams;
|
use models::params::user::{CreateUserParams, UpdateUserParams};
|
||||||
use models::schemas::{
|
use models::schemas::{
|
||||||
thought::{ThoughtListSchema, ThoughtSchema},
|
thought::{ThoughtListSchema, ThoughtSchema},
|
||||||
user::{UserListSchema, UserSchema},
|
user::{UserListSchema, UserSchema},
|
||||||
@@ -18,10 +18,15 @@ use models::schemas::{
|
|||||||
user_follow_delete,
|
user_follow_delete,
|
||||||
user_inbox_post,
|
user_inbox_post,
|
||||||
user_outbox_get,
|
user_outbox_get,
|
||||||
|
get_me,
|
||||||
|
update_me,
|
||||||
|
get_user_followers,
|
||||||
|
get_user_following
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
CreateUserParams,
|
CreateUserParams,
|
||||||
UserListSchema,
|
UserListSchema,
|
||||||
|
UpdateUserParams,
|
||||||
UserSchema,
|
UserSchema,
|
||||||
ThoughtSchema,
|
ThoughtSchema,
|
||||||
ThoughtListSchema,
|
ThoughtListSchema,
|
||||||
|
@@ -12,7 +12,4 @@ path = "src/lib.rs"
|
|||||||
models = { path = "../models" }
|
models = { path = "../models" }
|
||||||
|
|
||||||
async-std = { version = "1.13.1", features = ["attributes", "tokio1"] }
|
async-std = { version = "1.13.1", features = ["attributes", "tokio1"] }
|
||||||
sea-orm-migration = { version = "1.1.12", features = [
|
sea-orm-migration = { version = "1.1.12", features = ["sqlx-postgres"] }
|
||||||
"sqlx-sqlite",
|
|
||||||
"sqlx-postgres",
|
|
||||||
] }
|
|
||||||
|
@@ -2,6 +2,12 @@ pub use sea_orm_migration::prelude::*;
|
|||||||
|
|
||||||
mod m20240101_000001_init;
|
mod m20240101_000001_init;
|
||||||
mod m20250905_000001_init;
|
mod m20250905_000001_init;
|
||||||
|
mod m20250906_100000_add_profile_fields;
|
||||||
|
mod m20250906_130237_add_tags;
|
||||||
|
mod m20250906_134056_add_api_keys;
|
||||||
|
mod m20250906_145148_add_reply_to_thoughts;
|
||||||
|
mod m20250906_145755_add_visibility_to_thoughts;
|
||||||
|
mod m20250906_231359_add_full_text_search;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -11,6 +17,12 @@ impl MigratorTrait for Migrator {
|
|||||||
vec![
|
vec![
|
||||||
Box::new(m20240101_000001_init::Migration),
|
Box::new(m20240101_000001_init::Migration),
|
||||||
Box::new(m20250905_000001_init::Migration),
|
Box::new(m20250905_000001_init::Migration),
|
||||||
|
Box::new(m20250906_100000_add_profile_fields::Migration),
|
||||||
|
Box::new(m20250906_130237_add_tags::Migration),
|
||||||
|
Box::new(m20250906_134056_add_api_keys::Migration),
|
||||||
|
Box::new(m20250906_145148_add_reply_to_thoughts::Migration),
|
||||||
|
Box::new(m20250906_145755_add_visibility_to_thoughts::Migration),
|
||||||
|
Box::new(m20250906_231359_add_full_text_search::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -13,10 +13,10 @@ impl MigrationTrait for Migration {
|
|||||||
.if_not_exists()
|
.if_not_exists()
|
||||||
.col(
|
.col(
|
||||||
ColumnDef::new(User::Id)
|
ColumnDef::new(User::Id)
|
||||||
.integer()
|
.uuid()
|
||||||
.not_null()
|
.not_null()
|
||||||
.auto_increment()
|
.primary_key()
|
||||||
.primary_key(),
|
.default(Expr::cust("gen_random_uuid()")),
|
||||||
)
|
)
|
||||||
.col(
|
.col(
|
||||||
ColumnDef::new(User::Username)
|
ColumnDef::new(User::Username)
|
||||||
|
@@ -13,8 +13,14 @@ impl MigrationTrait for Migration {
|
|||||||
Table::create()
|
Table::create()
|
||||||
.table(Thought::Table)
|
.table(Thought::Table)
|
||||||
.if_not_exists()
|
.if_not_exists()
|
||||||
.col(pk_auto(Thought::Id))
|
.col(
|
||||||
.col(integer(Thought::AuthorId).not_null())
|
ColumnDef::new(Thought::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key()
|
||||||
|
.default(Expr::cust("gen_random_uuid()")),
|
||||||
|
)
|
||||||
|
.col(uuid(Thought::AuthorId).not_null())
|
||||||
.foreign_key(
|
.foreign_key(
|
||||||
ForeignKey::create()
|
ForeignKey::create()
|
||||||
.name("fk_thought_author_id")
|
.name("fk_thought_author_id")
|
||||||
@@ -39,13 +45,13 @@ impl MigrationTrait for Migration {
|
|||||||
Table::create()
|
Table::create()
|
||||||
.table(Follow::Table)
|
.table(Follow::Table)
|
||||||
.if_not_exists()
|
.if_not_exists()
|
||||||
.col(integer(Follow::FollowerId).not_null())
|
.col(uuid(Follow::FollowerId).not_null())
|
||||||
.col(integer(Follow::FollowedId).not_null())
|
.col(uuid(Follow::FollowingId).not_null())
|
||||||
// Composite Primary Key to ensure a user can only follow another once
|
// Composite Primary Key to ensure a user can only follow another once
|
||||||
.primary_key(
|
.primary_key(
|
||||||
Index::create()
|
Index::create()
|
||||||
.col(Follow::FollowerId)
|
.col(Follow::FollowerId)
|
||||||
.col(Follow::FollowedId),
|
.col(Follow::FollowingId),
|
||||||
)
|
)
|
||||||
.foreign_key(
|
.foreign_key(
|
||||||
ForeignKey::create()
|
ForeignKey::create()
|
||||||
@@ -56,8 +62,8 @@ impl MigrationTrait for Migration {
|
|||||||
)
|
)
|
||||||
.foreign_key(
|
.foreign_key(
|
||||||
ForeignKey::create()
|
ForeignKey::create()
|
||||||
.name("fk_follow_followed_id")
|
.name("fk_follow_following_id")
|
||||||
.from(Follow::Table, Follow::FollowedId)
|
.from(Follow::Table, Follow::FollowingId)
|
||||||
.to(User::Table, User::Id)
|
.to(User::Table, User::Id)
|
||||||
.on_delete(ForeignKeyAction::Cascade),
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
@@ -77,7 +83,7 @@ impl MigrationTrait for Migration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(DeriveIden)]
|
#[derive(DeriveIden)]
|
||||||
enum Thought {
|
pub enum Thought {
|
||||||
Table,
|
Table,
|
||||||
Id,
|
Id,
|
||||||
AuthorId,
|
AuthorId,
|
||||||
@@ -86,10 +92,10 @@ enum Thought {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(DeriveIden)]
|
#[derive(DeriveIden)]
|
||||||
enum Follow {
|
pub enum Follow {
|
||||||
Table,
|
Table,
|
||||||
// The user who is initiating the follow
|
// The user who is initiating the follow
|
||||||
FollowerId,
|
FollowerId,
|
||||||
// The user who is being followed
|
// The user who is being followed
|
||||||
FollowedId,
|
FollowingId,
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,107 @@
|
|||||||
|
use super::m20240101_000001_init::User;
|
||||||
|
use sea_orm_migration::{prelude::*, schema::*};
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(User::Table)
|
||||||
|
.add_column(string_null(UserExtension::Email).unique_key())
|
||||||
|
.add_column(string_null(UserExtension::DisplayName))
|
||||||
|
.add_column(string_null(UserExtension::Bio))
|
||||||
|
.add_column(text_null(UserExtension::AvatarUrl))
|
||||||
|
.add_column(text_null(UserExtension::HeaderUrl))
|
||||||
|
.add_column(text_null(UserExtension::CustomCss))
|
||||||
|
.add_column(
|
||||||
|
timestamp_with_time_zone(UserExtension::CreatedAt)
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.add_column(
|
||||||
|
timestamp_with_time_zone(UserExtension::UpdatedAt)
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(TopFriends::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(uuid(TopFriends::UserId).not_null())
|
||||||
|
.col(uuid(TopFriends::FriendId).not_null())
|
||||||
|
.col(small_integer(TopFriends::Position).not_null())
|
||||||
|
.primary_key(
|
||||||
|
Index::create()
|
||||||
|
.col(TopFriends::UserId)
|
||||||
|
.col(TopFriends::FriendId),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_top_friends_user_id")
|
||||||
|
.from(TopFriends::Table, TopFriends::UserId)
|
||||||
|
.to(User::Table, User::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_top_friends_friend_id")
|
||||||
|
.from(TopFriends::Table, TopFriends::FriendId)
|
||||||
|
.to(User::Table, User::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(TopFriends::Table).to_owned())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(User::Table)
|
||||||
|
.drop_column(UserExtension::Email)
|
||||||
|
.drop_column(UserExtension::DisplayName)
|
||||||
|
.drop_column(UserExtension::Bio)
|
||||||
|
.drop_column(UserExtension::AvatarUrl)
|
||||||
|
.drop_column(UserExtension::HeaderUrl)
|
||||||
|
.drop_column(UserExtension::CustomCss)
|
||||||
|
.drop_column(UserExtension::CreatedAt)
|
||||||
|
.drop_column(UserExtension::UpdatedAt)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum UserExtension {
|
||||||
|
Email,
|
||||||
|
DisplayName,
|
||||||
|
Bio,
|
||||||
|
AvatarUrl,
|
||||||
|
HeaderUrl,
|
||||||
|
CustomCss,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum TopFriends {
|
||||||
|
Table,
|
||||||
|
UserId,
|
||||||
|
FriendId,
|
||||||
|
Position,
|
||||||
|
}
|
74
thoughts-backend/migration/src/m20250906_130237_add_tags.rs
Normal file
74
thoughts-backend/migration/src/m20250906_130237_add_tags.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
use super::m20250905_000001_init::Thought;
|
||||||
|
use sea_orm_migration::{prelude::*, schema::*};
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Tag::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(pk_auto(Tag::Id))
|
||||||
|
.col(string(Tag::Name).not_null().unique_key())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(ThoughtTag::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(uuid(ThoughtTag::ThoughtId).not_null())
|
||||||
|
.col(integer(ThoughtTag::TagId).not_null())
|
||||||
|
.primary_key(
|
||||||
|
Index::create()
|
||||||
|
.col(ThoughtTag::ThoughtId)
|
||||||
|
.col(ThoughtTag::TagId),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_thought_tag_thought_id")
|
||||||
|
.from(ThoughtTag::Table, ThoughtTag::ThoughtId)
|
||||||
|
.to(Thought::Table, Thought::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_thought_tag_tag_id")
|
||||||
|
.from(ThoughtTag::Table, ThoughtTag::TagId)
|
||||||
|
.to(Tag::Table, Tag::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(ThoughtTag::Table).to_owned())
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Tag::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Tag {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum ThoughtTag {
|
||||||
|
Table,
|
||||||
|
ThoughtId,
|
||||||
|
TagId,
|
||||||
|
}
|
@@ -0,0 +1,69 @@
|
|||||||
|
use super::m20240101_000001_init::User;
|
||||||
|
use sea_orm_migration::{prelude::*, schema::*};
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(ApiKey::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(ApiKey::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key()
|
||||||
|
.default(Expr::cust("gen_random_uuid()")),
|
||||||
|
)
|
||||||
|
.col(uuid(ApiKey::UserId).not_null())
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_api_key_user_id")
|
||||||
|
.from(ApiKey::Table, ApiKey::UserId)
|
||||||
|
.to(User::Table, User::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.col(text(ApiKey::KeyHash).not_null().unique_key())
|
||||||
|
.col(string(ApiKey::Name).not_null())
|
||||||
|
.col(
|
||||||
|
timestamp_with_time_zone(ApiKey::CreatedAt)
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(ApiKey::KeyPrefix).string_len(8).not_null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx-api_keys-key_prefix")
|
||||||
|
.table(ApiKey::Table)
|
||||||
|
.col(ApiKey::KeyPrefix)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(ApiKey::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum ApiKey {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
UserId,
|
||||||
|
KeyHash,
|
||||||
|
Name,
|
||||||
|
CreatedAt,
|
||||||
|
KeyPrefix,
|
||||||
|
}
|
@@ -0,0 +1,46 @@
|
|||||||
|
use sea_orm_migration::{prelude::*, schema::*};
|
||||||
|
|
||||||
|
use crate::m20250905_000001_init::Thought;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Thought::Table)
|
||||||
|
.add_column(uuid_null(ThoughtExtension::ReplyToId))
|
||||||
|
.add_foreign_key(
|
||||||
|
TableForeignKey::new()
|
||||||
|
.name("fk_thought_reply_to_id")
|
||||||
|
.from_tbl(Thought::Table)
|
||||||
|
.from_col(ThoughtExtension::ReplyToId)
|
||||||
|
.to_tbl(Thought::Table)
|
||||||
|
.to_col(Thought::Id)
|
||||||
|
.on_delete(ForeignKeyAction::SetNull),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Thought::Table)
|
||||||
|
.drop_foreign_key(Alias::new("fk_thought_reply_to_id"))
|
||||||
|
.drop_column(ThoughtExtension::ReplyToId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum ThoughtExtension {
|
||||||
|
ReplyToId,
|
||||||
|
}
|
@@ -0,0 +1,59 @@
|
|||||||
|
use super::m20250905_000001_init::Thought;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
"CREATE TYPE thought_visibility AS ENUM ('public', 'friends_only', 'private')",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 2. Add the new column to the thoughts table
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Thought::Table)
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(ThoughtExtension::Visibility)
|
||||||
|
.enumeration(
|
||||||
|
"thought_visibility",
|
||||||
|
["public", "friends_only", "private"],
|
||||||
|
)
|
||||||
|
.not_null()
|
||||||
|
.default("public"), // Default new thoughts to public
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Thought::Table)
|
||||||
|
.drop_column(ThoughtExtension::Visibility)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Drop the ENUM type
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("DROP TYPE thought_visibility")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum ThoughtExtension {
|
||||||
|
Visibility,
|
||||||
|
}
|
@@ -0,0 +1,48 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// --- Users Table ---
|
||||||
|
// Add the tsvector column for users
|
||||||
|
manager.get_connection().execute_unprepared(
|
||||||
|
"ALTER TABLE \"user\" ADD COLUMN \"search_document\" tsvector \
|
||||||
|
GENERATED ALWAYS AS (to_tsvector('english', username || ' ' || coalesce(display_name, ''))) STORED"
|
||||||
|
).await?;
|
||||||
|
// Add the GIN index for users
|
||||||
|
manager.get_connection().execute_unprepared(
|
||||||
|
"CREATE INDEX \"user_search_document_idx\" ON \"user\" USING GIN(\"search_document\")"
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
// --- Thoughts Table ---
|
||||||
|
// Add the tsvector column for thoughts
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
"ALTER TABLE \"thought\" ADD COLUMN \"search_document\" tsvector \
|
||||||
|
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Add the GIN index for thoughts
|
||||||
|
manager.get_connection().execute_unprepared(
|
||||||
|
"CREATE INDEX \"thought_search_document_idx\" ON \"thought\" USING GIN(\"search_document\")"
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("ALTER TABLE \"user\" DROP COLUMN \"search_document\"")
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("ALTER TABLE \"thought\" DROP COLUMN \"search_document\"")
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
@@ -13,10 +13,10 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
sea-orm = { workspace = true, features = [
|
sea-orm = { workspace = true, features = [
|
||||||
"sqlx-postgres",
|
"sqlx-postgres",
|
||||||
"sqlx-sqlite",
|
|
||||||
"runtime-tokio-rustls",
|
"runtime-tokio-rustls",
|
||||||
"macros",
|
"macros",
|
||||||
] }
|
] }
|
||||||
|
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
||||||
validator = { workspace = true, features = ["derive"] }
|
validator = { workspace = true, features = ["derive"] }
|
||||||
utoipa = { workspace = true }
|
utoipa = { workspace = true }
|
||||||
|
|
||||||
|
32
thoughts-backend/models/src/domains/api_key.rs
Normal file
32
thoughts-backend/models/src/domains/api_key.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
|
#[sea_orm(table_name = "api_key")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub key_prefix: String,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub key_hash: String,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::user::Column::Id"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
@@ -3,10 +3,10 @@ use sea_orm::entity::prelude::*;
|
|||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
#[sea_orm(table_name = "follow")]
|
#[sea_orm(table_name = "follow")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
#[sea_orm(primary_key)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub follower_id: i32,
|
pub follower_id: Uuid,
|
||||||
#[sea_orm(primary_key)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub followed_id: i32,
|
pub following_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
@@ -21,12 +21,18 @@ pub enum Relation {
|
|||||||
Follower,
|
Follower,
|
||||||
#[sea_orm(
|
#[sea_orm(
|
||||||
belongs_to = "super::user::Entity",
|
belongs_to = "super::user::Entity",
|
||||||
from = "Column::FollowedId",
|
from = "Column::FollowingId",
|
||||||
to = "super::user::Column::Id",
|
to = "super::user::Column::Id",
|
||||||
on_update = "NoAction",
|
on_update = "NoAction",
|
||||||
on_delete = "Cascade"
|
on_delete = "Cascade"
|
||||||
)]
|
)]
|
||||||
Followed,
|
Following,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Follower.def()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
|
pub mod api_key;
|
||||||
pub mod follow;
|
pub mod follow;
|
||||||
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
|
pub mod thought_tag;
|
||||||
|
pub mod top_friends;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
@@ -1,5 +1,9 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||||
|
|
||||||
|
pub use super::api_key::Entity as ApiKey;
|
||||||
pub use super::follow::Entity as Follow;
|
pub use super::follow::Entity as Follow;
|
||||||
|
pub use super::tag::Entity as Tag;
|
||||||
pub use super::thought::Entity as Thought;
|
pub use super::thought::Entity as Thought;
|
||||||
|
pub use super::thought_tag::Entity as ThoughtTag;
|
||||||
|
pub use super::top_friends::Entity as TopFriends;
|
||||||
pub use super::user::Entity as User;
|
pub use super::user::Entity as User;
|
||||||
|
27
thoughts-backend/models/src/domains/tag.rs
Normal file
27
thoughts-backend/models/src/domains/tag.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
|
#[sea_orm(table_name = "tag")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::thought_tag::Entity")]
|
||||||
|
ThoughtTag,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::thought::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
super::thought_tag::Relation::Thought.def()
|
||||||
|
}
|
||||||
|
fn via() -> Option<RelationDef> {
|
||||||
|
Some(super::thought_tag::Relation::Tag.def().rev())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
@@ -1,13 +1,32 @@
|
|||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, ToSchema,
|
||||||
|
)]
|
||||||
|
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "thought_visibility")]
|
||||||
|
pub enum Visibility {
|
||||||
|
#[sea_orm(string_value = "public")]
|
||||||
|
Public,
|
||||||
|
#[sea_orm(string_value = "friends_only")]
|
||||||
|
FriendsOnly,
|
||||||
|
#[sea_orm(string_value = "private")]
|
||||||
|
Private,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
#[sea_orm(table_name = "thought")]
|
#[sea_orm(table_name = "thought")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
#[sea_orm(primary_key)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
pub author_id: i32,
|
pub author_id: Uuid,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
pub reply_to_id: Option<Uuid>,
|
||||||
|
pub visibility: Visibility,
|
||||||
pub created_at: DateTimeWithTimeZone,
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
|
||||||
|
pub search_document: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
@@ -20,6 +39,9 @@ pub enum Relation {
|
|||||||
on_delete = "Cascade"
|
on_delete = "Cascade"
|
||||||
)]
|
)]
|
||||||
User,
|
User,
|
||||||
|
|
||||||
|
#[sea_orm(has_many = "super::thought_tag::Entity")]
|
||||||
|
ThoughtTag,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Related<super::user::Entity> for Entity {
|
impl Related<super::user::Entity> for Entity {
|
||||||
@@ -28,4 +50,13 @@ impl Related<super::user::Entity> for Entity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Related<super::tag::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
super::thought_tag::Relation::Tag.def()
|
||||||
|
}
|
||||||
|
fn via() -> Option<RelationDef> {
|
||||||
|
Some(super::thought_tag::Relation::Thought.def().rev())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
40
thoughts-backend/models/src/domains/thought_tag.rs
Normal file
40
thoughts-backend/models/src/domains/thought_tag.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
|
#[sea_orm(table_name = "thought_tag")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub thought_id: Uuid,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub tag_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::thought::Entity",
|
||||||
|
from = "Column::ThoughtId",
|
||||||
|
to = "super::thought::Column::Id"
|
||||||
|
)]
|
||||||
|
Thought,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::tag::Entity",
|
||||||
|
from = "Column::TagId",
|
||||||
|
to = "super::tag::Column::Id"
|
||||||
|
)]
|
||||||
|
Tag,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::thought::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Thought.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::tag::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Tag.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
35
thoughts-backend/models/src/domains/top_friends.rs
Normal file
35
thoughts-backend/models/src/domains/top_friends.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
|
#[sea_orm(table_name = "top_friends")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: Uuid,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub friend_id: Uuid,
|
||||||
|
pub position: i16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::user::Column::Id"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::FriendId",
|
||||||
|
to = "super::user::Column::Id"
|
||||||
|
)]
|
||||||
|
Friend,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
@@ -5,14 +5,34 @@ use sea_orm::entity::prelude::*;
|
|||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
#[sea_orm(table_name = "user")]
|
#[sea_orm(table_name = "user")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
#[sea_orm(primary_key)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
#[sea_orm(unique)]
|
#[sea_orm(unique)]
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password_hash: Option<String>,
|
pub password_hash: Option<String>,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub header_url: Option<String>,
|
||||||
|
pub custom_css: Option<String>,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
|
||||||
|
pub search_document: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
pub enum Relation {}
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::thought::Entity")]
|
||||||
|
Thought,
|
||||||
|
|
||||||
|
#[sea_orm(has_many = "super::top_friends::Entity")]
|
||||||
|
TopFriends,
|
||||||
|
|
||||||
|
#[sea_orm(has_many = "super::api_key::Entity")]
|
||||||
|
ApiKey,
|
||||||
|
}
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
@@ -6,6 +6,8 @@ use validator::Validate;
|
|||||||
pub struct RegisterParams {
|
pub struct RegisterParams {
|
||||||
#[validate(length(min = 3))]
|
#[validate(length(min = 3))]
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
#[validate(email)]
|
||||||
|
pub email: String,
|
||||||
#[validate(length(min = 6))]
|
#[validate(length(min = 6))]
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,10 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::domains::thought::Visibility;
|
||||||
|
|
||||||
#[derive(Deserialize, Validate, ToSchema)]
|
#[derive(Deserialize, Validate, ToSchema)]
|
||||||
pub struct CreateThoughtParams {
|
pub struct CreateThoughtParams {
|
||||||
#[validate(length(
|
#[validate(length(
|
||||||
@@ -10,4 +13,7 @@ pub struct CreateThoughtParams {
|
|||||||
message = "Content must be between 1 and 128 characters"
|
message = "Content must be between 1 and 128 characters"
|
||||||
))]
|
))]
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
pub visibility: Option<Visibility>,
|
||||||
|
#[serde(rename = "replyToId")]
|
||||||
|
pub reply_to_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
@@ -9,3 +9,30 @@ pub struct CreateUserParams {
|
|||||||
#[validate(length(min = 6))]
|
#[validate(length(min = 6))]
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, ToSchema, Default)]
|
||||||
|
pub struct UpdateUserParams {
|
||||||
|
#[validate(length(max = 50))]
|
||||||
|
#[schema(example = "Frutiger Aero Fan")]
|
||||||
|
#[serde(rename = "displayName")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
|
||||||
|
#[validate(length(max = 4000))]
|
||||||
|
#[schema(example = "Est. 2004")]
|
||||||
|
pub bio: Option<String>,
|
||||||
|
|
||||||
|
#[validate(url)]
|
||||||
|
#[serde(rename = "avatarUrl")]
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
|
||||||
|
#[validate(url)]
|
||||||
|
#[serde(rename = "headerUrl")]
|
||||||
|
pub header_url: Option<String>,
|
||||||
|
#[serde(rename = "customCss")]
|
||||||
|
pub custom_css: Option<String>,
|
||||||
|
|
||||||
|
#[validate(length(max = 8))]
|
||||||
|
#[schema(example = json!(["username1", "username2"]))]
|
||||||
|
#[serde(rename = "topFriends")]
|
||||||
|
pub top_friends: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
@@ -1 +1,2 @@
|
|||||||
|
pub mod pagination;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
27
thoughts-backend/models/src/queries/pagination.rs
Normal file
27
thoughts-backend/models/src/queries/pagination.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use utoipa::IntoParams;
|
||||||
|
|
||||||
|
const DEFAULT_PAGE: u64 = 1;
|
||||||
|
const DEFAULT_PAGE_SIZE: u64 = 20;
|
||||||
|
|
||||||
|
#[derive(Deserialize, IntoParams)]
|
||||||
|
pub struct PaginationQuery {
|
||||||
|
#[param(nullable = true, example = 1)]
|
||||||
|
page: Option<u64>,
|
||||||
|
#[param(nullable = true, example = 20)]
|
||||||
|
page_size: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaginationQuery {
|
||||||
|
pub fn page(&self) -> u64 {
|
||||||
|
self.page.unwrap_or(DEFAULT_PAGE).max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page_size(&self) -> u64 {
|
||||||
|
self.page_size.unwrap_or(DEFAULT_PAGE_SIZE).max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn offset(&self) -> u64 {
|
||||||
|
(self.page() - 1) * self.page_size()
|
||||||
|
}
|
||||||
|
}
|
64
thoughts-backend/models/src/schemas/api_key.rs
Normal file
64
thoughts-backend/models/src/schemas/api_key.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use crate::domains::api_key;
|
||||||
|
use common::DateTimeWithTimeZoneWrapper;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ApiKeySchema {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "keyPrefix")]
|
||||||
|
pub key_prefix: String,
|
||||||
|
#[serde(rename = "createdAt")]
|
||||||
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ApiKeyResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub key: ApiKeySchema,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", rename = "plaintextKey")]
|
||||||
|
pub plaintext_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiKeyResponse {
|
||||||
|
pub fn from_parts(model: api_key::Model, plaintext_key: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
key: ApiKeySchema {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
key_prefix: model.key_prefix,
|
||||||
|
created_at: model.created_at.into(),
|
||||||
|
},
|
||||||
|
plaintext_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ApiKeyListSchema {
|
||||||
|
#[serde(rename = "apiKeys")]
|
||||||
|
pub api_keys: Vec<ApiKeySchema>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Vec<api_key::Model>> for ApiKeyListSchema {
|
||||||
|
fn from(keys: Vec<api_key::Model>) -> Self {
|
||||||
|
Self {
|
||||||
|
api_keys: keys
|
||||||
|
.into_iter()
|
||||||
|
.map(|k| ApiKeySchema {
|
||||||
|
id: k.id,
|
||||||
|
name: k.name,
|
||||||
|
key_prefix: k.key_prefix,
|
||||||
|
created_at: k.created_at.into(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct ApiKeyRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
@@ -1,2 +1,5 @@
|
|||||||
|
pub mod api_key;
|
||||||
|
pub mod pagination;
|
||||||
|
pub mod search;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
12
thoughts-backend/models/src/schemas/pagination.rs
Normal file
12
thoughts-backend/models/src/schemas/pagination.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PaginatedResponse<T> {
|
||||||
|
pub items: Vec<T>,
|
||||||
|
pub page: u64,
|
||||||
|
pub page_size: u64,
|
||||||
|
pub total_pages: u64,
|
||||||
|
pub total_items: u64,
|
||||||
|
}
|
9
thoughts-backend/models/src/schemas/search.rs
Normal file
9
thoughts-backend/models/src/schemas/search.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use super::{thought::ThoughtListSchema, user::UserListSchema};
|
||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct SearchResultsSchema {
|
||||||
|
pub users: UserListSchema,
|
||||||
|
pub thoughts: ThoughtListSchema,
|
||||||
|
}
|
@@ -1,16 +1,24 @@
|
|||||||
use crate::domains::{thought, user};
|
use crate::domains::{
|
||||||
|
thought::{self, Visibility},
|
||||||
|
user,
|
||||||
|
};
|
||||||
use common::DateTimeWithTimeZoneWrapper;
|
use common::DateTimeWithTimeZoneWrapper;
|
||||||
use sea_orm::FromQueryResult;
|
use sea_orm::FromQueryResult;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema, FromQueryResult, Debug)]
|
#[derive(Serialize, ToSchema, FromQueryResult, Debug, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ThoughtSchema {
|
pub struct ThoughtSchema {
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
#[schema(example = "frutiger")]
|
#[schema(example = "frutiger")]
|
||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
|
pub author_display_name: Option<String>,
|
||||||
#[schema(example = "This is my first thought! #welcome")]
|
#[schema(example = "This is my first thought! #welcome")]
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
pub visibility: Visibility,
|
||||||
|
pub reply_to_id: Option<Uuid>,
|
||||||
pub created_at: DateTimeWithTimeZoneWrapper,
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,13 +27,17 @@ impl ThoughtSchema {
|
|||||||
Self {
|
Self {
|
||||||
id: thought.id,
|
id: thought.id,
|
||||||
author_username: author.username.clone(),
|
author_username: author.username.clone(),
|
||||||
|
author_display_name: author.display_name.clone(),
|
||||||
content: thought.content.clone(),
|
content: thought.content.clone(),
|
||||||
|
visibility: thought.visibility.clone(),
|
||||||
|
reply_to_id: thought.reply_to_id,
|
||||||
created_at: thought.created_at.into(),
|
created_at: thought.created_at.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ThoughtListSchema {
|
pub struct ThoughtListSchema {
|
||||||
pub thoughts: Vec<ThoughtSchema>,
|
pub thoughts: Vec<ThoughtSchema>,
|
||||||
}
|
}
|
||||||
@@ -38,11 +50,14 @@ impl From<Vec<ThoughtSchema>> for ThoughtListSchema {
|
|||||||
|
|
||||||
#[derive(Debug, FromQueryResult)]
|
#[derive(Debug, FromQueryResult)]
|
||||||
pub struct ThoughtWithAuthor {
|
pub struct ThoughtWithAuthor {
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub created_at: sea_orm::prelude::DateTimeWithTimeZone,
|
pub created_at: sea_orm::prelude::DateTimeWithTimeZone,
|
||||||
pub author_id: i32,
|
pub visibility: Visibility,
|
||||||
|
pub author_id: Uuid,
|
||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
|
pub author_display_name: Option<String>,
|
||||||
|
pub reply_to_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ThoughtWithAuthor> for ThoughtSchema {
|
impl From<ThoughtWithAuthor> for ThoughtSchema {
|
||||||
@@ -50,8 +65,24 @@ impl From<ThoughtWithAuthor> for ThoughtSchema {
|
|||||||
Self {
|
Self {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
author_username: model.author_username,
|
author_username: model.author_username,
|
||||||
|
author_display_name: model.author_display_name,
|
||||||
content: model.content,
|
content: model.content,
|
||||||
created_at: model.created_at.into(),
|
created_at: model.created_at.into(),
|
||||||
|
reply_to_id: model.reply_to_id,
|
||||||
|
visibility: model.visibility,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ThoughtThreadSchema {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub author_username: String,
|
||||||
|
pub author_display_name: Option<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub visibility: Visibility,
|
||||||
|
pub reply_to_id: Option<Uuid>,
|
||||||
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
|
pub replies: Vec<ThoughtThreadSchema>,
|
||||||
|
}
|
||||||
|
@@ -1,12 +1,38 @@
|
|||||||
|
use common::DateTimeWithTimeZoneWrapper;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::domains::user;
|
use crate::domains::user;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct UserSchema {
|
pub struct UserSchema {
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub header_url: Option<String>,
|
||||||
|
pub custom_css: Option<String>,
|
||||||
|
pub top_friends: Vec<String>,
|
||||||
|
pub joined_at: DateTimeWithTimeZoneWrapper,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(user::Model, Vec<user::Model>)> for UserSchema {
|
||||||
|
fn from((user, top_friends): (user::Model, Vec<user::Model>)) -> Self {
|
||||||
|
Self {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
bio: user.bio,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
header_url: user.header_url,
|
||||||
|
custom_css: user.custom_css,
|
||||||
|
top_friends: top_friends.into_iter().map(|u| u.username).collect(),
|
||||||
|
joined_at: user.created_at.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<user::Model> for UserSchema {
|
impl From<user::Model> for UserSchema {
|
||||||
@@ -14,6 +40,13 @@ impl From<user::Model> for UserSchema {
|
|||||||
Self {
|
Self {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
bio: user.bio,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
header_url: user.header_url,
|
||||||
|
custom_css: user.custom_css,
|
||||||
|
top_friends: vec![],
|
||||||
|
joined_at: user.created_at.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,3 +63,18 @@ impl From<Vec<user::Model>> for UserListSchema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MeSchema {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub header_url: Option<String>,
|
||||||
|
pub custom_css: Option<String>,
|
||||||
|
pub top_friends: Vec<String>,
|
||||||
|
pub joined_at: DateTimeWithTimeZoneWrapper,
|
||||||
|
pub following: Vec<UserSchema>,
|
||||||
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
use api::{setup_db, setup_router};
|
use api::{setup_db, setup_router};
|
||||||
use doc::ApiDoc;
|
use doc::ApiDocExt;
|
||||||
use utils::migrate;
|
use utils::migrate;
|
||||||
|
|
||||||
pub async fn run(db_url: &str) -> shuttle_axum::ShuttleAxum {
|
pub async fn run(db_url: &str) -> shuttle_axum::ShuttleAxum {
|
||||||
|
@@ -1,10 +1,7 @@
|
|||||||
use api::{setup_config, setup_db, setup_router};
|
use api::{setup_config, setup_db, setup_router};
|
||||||
use doc::ApiDoc;
|
use utils::migrate;
|
||||||
use utils::{create_dev_db, migrate};
|
|
||||||
|
|
||||||
async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) {
|
async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) {
|
||||||
tracing::info!("Worker {} started", child_num);
|
|
||||||
|
|
||||||
let conn = setup_db(db_url, prefork).await;
|
let conn = setup_db(db_url, prefork).await;
|
||||||
|
|
||||||
if child_num == 0 {
|
if child_num == 0 {
|
||||||
@@ -13,7 +10,7 @@ async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net:
|
|||||||
|
|
||||||
let config = setup_config();
|
let config = setup_config();
|
||||||
|
|
||||||
let router = setup_router(conn, &config).attach_doc();
|
let router = setup_router(conn, &config);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::from_std(listener).expect("bind to port");
|
let listener = tokio::net::TcpListener::from_std(listener).expect("bind to port");
|
||||||
axum::serve(listener, router).await.expect("start server");
|
axum::serve(listener, router).await.expect("start server");
|
||||||
@@ -22,7 +19,6 @@ async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net:
|
|||||||
#[cfg(feature = "prefork")]
|
#[cfg(feature = "prefork")]
|
||||||
fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
|
fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
|
||||||
let db_url: &'static str = Box::leak(db_url.to_owned().into_boxed_str());
|
let db_url: &'static str = Box::leak(db_url.to_owned().into_boxed_str());
|
||||||
create_dev_db(db_url);
|
|
||||||
|
|
||||||
let num_of_cores = std::thread::available_parallelism().unwrap().get() as u32;
|
let num_of_cores = std::thread::available_parallelism().unwrap().get() as u32;
|
||||||
let is_parent = prefork::Prefork::from_resource(listener)
|
let is_parent = prefork::Prefork::from_resource(listener)
|
||||||
@@ -37,18 +33,17 @@ fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_non_prefork(db_url: &str, listener: std::net::TcpListener) {
|
fn run_non_prefork(db_url: &str, listener: std::net::TcpListener) {
|
||||||
create_dev_db(db_url);
|
|
||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
rt.block_on(worker(0, db_url, false, listener));
|
rt.block_on(worker(0, db_url, false, listener));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
tracing::info!("Starting server...");
|
||||||
let config = setup_config();
|
let config = setup_config();
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind(config.get_server_url()).expect("bind to port");
|
let listener = std::net::TcpListener::bind(config.get_server_url()).expect("bind to port");
|
||||||
listener.set_nonblocking(true).expect("non blocking failed");
|
listener.set_nonblocking(true).expect("non blocking failed");
|
||||||
tracing::debug!("listening on http://{}", listener.local_addr().unwrap());
|
tracing::info!("listening on http://{}", listener.local_addr().unwrap());
|
||||||
|
|
||||||
#[cfg(feature = "prefork")]
|
#[cfg(feature = "prefork")]
|
||||||
if config.prefork {
|
if config.prefork {
|
||||||
|
@@ -1,146 +0,0 @@
|
|||||||
use crate::api::main::{create_user_with_password, setup};
|
|
||||||
use axum::http::{header, StatusCode};
|
|
||||||
use http_body_util::BodyExt;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use utils::testing::{
|
|
||||||
make_get_request, make_jwt_request, make_post_request, make_request_with_headers,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_webfinger_discovery() {
|
|
||||||
let app = setup().await;
|
|
||||||
create_user_with_password(&app.db, "testuser", "password123").await;
|
|
||||||
|
|
||||||
// 1. Valid WebFinger lookup for existing user
|
|
||||||
let url = "/.well-known/webfinger?resource=acct:testuser@localhost:3000";
|
|
||||||
let response = make_get_request(app.router.clone(), url, None).await;
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
|
||||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
|
||||||
assert_eq!(v["subject"], "acct:testuser@localhost:3000");
|
|
||||||
assert_eq!(
|
|
||||||
v["links"][0]["href"],
|
|
||||||
"http://localhost:3000/users/testuser"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. WebFinger lookup for a non-existent user
|
|
||||||
let response = make_get_request(
|
|
||||||
app.router.clone(),
|
|
||||||
"/.well-known/webfinger?resource=acct:nobody@localhost:3000",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_user_actor_endpoint() {
|
|
||||||
let app = setup().await;
|
|
||||||
create_user_with_password(&app.db, "testuser", "password123").await;
|
|
||||||
|
|
||||||
let response = make_request_with_headers(
|
|
||||||
app.router.clone(),
|
|
||||||
"/users/testuser",
|
|
||||||
"GET",
|
|
||||||
None,
|
|
||||||
vec![(
|
|
||||||
header::ACCEPT,
|
|
||||||
"application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
|
|
||||||
)],
|
|
||||||
).await;
|
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
let content_type = response.headers().get(header::CONTENT_TYPE).unwrap();
|
|
||||||
assert_eq!(content_type, "application/activity+json");
|
|
||||||
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
|
||||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
|
||||||
assert_eq!(v["type"], "Person");
|
|
||||||
assert_eq!(v["preferredUsername"], "testuser");
|
|
||||||
assert_eq!(v["id"], "http://localhost:3000/users/testuser");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_user_inbox_follow() {
|
|
||||||
let app = setup().await;
|
|
||||||
// user1 will be followed
|
|
||||||
create_user_with_password(&app.db, "user1", "password123").await;
|
|
||||||
// user2 will be the follower
|
|
||||||
create_user_with_password(&app.db, "user2", "password123").await;
|
|
||||||
|
|
||||||
// Construct a follow activity from user2, targeting user1
|
|
||||||
let follow_activity = json!({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": "http://localhost:3000/some-unique-id",
|
|
||||||
"type": "Follow",
|
|
||||||
"actor": "http://localhost:3000/users/user2", // The actor is user2
|
|
||||||
"object": "http://localhost:3000/users/user1"
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// POST the activity to user1's inbox
|
|
||||||
let response = make_post_request(
|
|
||||||
app.router.clone(),
|
|
||||||
"/users/user1/inbox",
|
|
||||||
follow_activity,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
|
||||||
|
|
||||||
// Verify that user2 is now following user1 in the database
|
|
||||||
let followers = app::persistence::follow::get_followed_ids(&app.db, 2)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(followers.contains(&1), "User2 should be following user1");
|
|
||||||
|
|
||||||
let following = app::persistence::follow::get_followed_ids(&app.db, 1)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
!following.contains(&2),
|
|
||||||
"User1 should now be followed by user2"
|
|
||||||
);
|
|
||||||
assert!(following.is_empty(), "User1 should not be following anyone");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_user_outbox_get() {
|
|
||||||
let app = setup().await;
|
|
||||||
create_user_with_password(&app.db, "testuser", "password123").await;
|
|
||||||
let token = super::main::login_user(app.router.clone(), "testuser", "password123").await;
|
|
||||||
|
|
||||||
// Create a thought first
|
|
||||||
let thought_body = json!({ "content": "This is a federated thought!" }).to_string();
|
|
||||||
make_jwt_request(
|
|
||||||
app.router.clone(),
|
|
||||||
"/thoughts",
|
|
||||||
"POST",
|
|
||||||
Some(thought_body),
|
|
||||||
&token,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Now, fetch the outbox
|
|
||||||
let response = make_request_with_headers(
|
|
||||||
app.router.clone(),
|
|
||||||
"/users/testuser/outbox",
|
|
||||||
"GET",
|
|
||||||
None,
|
|
||||||
vec![(header::ACCEPT, "application/activity+json")],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
|
||||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(v["type"], "OrderedCollection");
|
|
||||||
assert_eq!(v["totalItems"], 1);
|
|
||||||
assert_eq!(v["orderedItems"][0]["type"], "Create");
|
|
||||||
assert_eq!(
|
|
||||||
v["orderedItems"][0]["object"]["content"],
|
|
||||||
"This is a federated thought!"
|
|
||||||
);
|
|
||||||
}
|
|
85
thoughts-backend/tests/api/api_key.rs
Normal file
85
thoughts-backend/tests/api/api_key.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
use crate::api::main::{create_user_with_password, login_user, setup};
|
||||||
|
use axum::http::{header, HeaderName, StatusCode};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use utils::testing::{make_jwt_request, make_request_with_headers};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_api_key_flow() {
|
||||||
|
let app = setup().await;
|
||||||
|
let _ = create_user_with_password(
|
||||||
|
&app.db,
|
||||||
|
"apikey_user",
|
||||||
|
"password123",
|
||||||
|
"apikey_user@example.com",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let jwt = login_user(app.router.clone(), "apikey_user", "password123").await;
|
||||||
|
|
||||||
|
// 1. Create a new API key using JWT auth
|
||||||
|
let create_body = json!({ "name": "My Test Key" }).to_string();
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/me/api-keys",
|
||||||
|
"POST",
|
||||||
|
Some(create_body),
|
||||||
|
&jwt,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
let plaintext_key = v["plaintextKey"]
|
||||||
|
.as_str()
|
||||||
|
.expect("Plaintext key not found")
|
||||||
|
.to_string();
|
||||||
|
let key_id = v["id"].as_str().expect("Key ID not found").to_string();
|
||||||
|
assert!(plaintext_key.starts_with("th_"));
|
||||||
|
|
||||||
|
// 2. Use the new API key to post a thought
|
||||||
|
|
||||||
|
let thought_body = json!({ "content": "Posting with an API key!" }).to_string();
|
||||||
|
let key = plaintext_key.clone();
|
||||||
|
let api_key_header = format!("ApiKey {}", key);
|
||||||
|
let content_type = "application/json";
|
||||||
|
let headers: Vec<(HeaderName, &str)> = vec![
|
||||||
|
(header::AUTHORIZATION, &api_key_header),
|
||||||
|
(header::CONTENT_TYPE, content_type),
|
||||||
|
];
|
||||||
|
|
||||||
|
let response = make_request_with_headers(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(thought_body),
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
// 3. Delete the API key using JWT auth
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
&format!("/users/me/api-keys/{}", key_id),
|
||||||
|
"DELETE",
|
||||||
|
None,
|
||||||
|
&jwt,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// 4. Try to use the deleted key again, expecting failure
|
||||||
|
let body = json!({ "content": "This should fail" }).to_string();
|
||||||
|
let headers: Vec<(HeaderName, &str)> = vec![
|
||||||
|
(header::AUTHORIZATION, &api_key_header),
|
||||||
|
(header::CONTENT_TYPE, content_type),
|
||||||
|
];
|
||||||
|
|
||||||
|
let response =
|
||||||
|
make_request_with_headers(app.router.clone(), "/thoughts", "POST", Some(body), headers)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
@@ -11,6 +11,7 @@ async fn test_auth_flow() {
|
|||||||
|
|
||||||
let register_body = json!({
|
let register_body = json!({
|
||||||
"username": "testuser",
|
"username": "testuser",
|
||||||
|
"email": "testuser@example.com",
|
||||||
"password": "password123"
|
"password": "password123"
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -20,13 +21,13 @@ async fn test_auth_flow() {
|
|||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v["username"], "testuser");
|
assert_eq!(v["username"], "testuser");
|
||||||
assert!(v["id"].is_number());
|
|
||||||
|
|
||||||
let response = make_post_request(
|
let response = make_post_request(
|
||||||
app.router.clone(),
|
app.router.clone(),
|
||||||
"/auth/register",
|
"/auth/register",
|
||||||
json!({
|
json!({
|
||||||
"username": "testuser",
|
"username": "testuser",
|
||||||
|
"email": "testuser@example.com",
|
||||||
"password": "password456"
|
"password": "password456"
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -49,6 +50,7 @@ async fn test_auth_flow() {
|
|||||||
|
|
||||||
let bad_login_body = json!({
|
let bad_login_body = json!({
|
||||||
"username": "testuser",
|
"username": "testuser",
|
||||||
|
"email": "testuser@example.com",
|
||||||
"password": "wrongpassword"
|
"password": "wrongpassword"
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
@@ -1,15 +1,18 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::main::{create_user_with_password, setup};
|
use super::main::{create_user_with_password, setup};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
|
use tokio::time::sleep;
|
||||||
use utils::testing::make_jwt_request;
|
use utils::testing::make_jwt_request;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_feed_and_user_thoughts() {
|
async fn test_feed_and_user_thoughts() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
create_user_with_password(&app.db, "user1", "password1").await;
|
create_user_with_password(&app.db, "user1", "password1", "user1@example.com").await;
|
||||||
create_user_with_password(&app.db, "user2", "password2").await;
|
create_user_with_password(&app.db, "user2", "password2", "user2@example.com").await;
|
||||||
create_user_with_password(&app.db, "user3", "password3").await;
|
create_user_with_password(&app.db, "user3", "password3", "user3@example.com").await;
|
||||||
|
|
||||||
// As user1, post a thought
|
// As user1, post a thought
|
||||||
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
||||||
@@ -59,9 +62,9 @@ async fn test_feed_and_user_thoughts() {
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v["thoughts"].as_array().unwrap().len(), 1);
|
assert_eq!(v["items"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(v["thoughts"][0]["author_username"], "user1");
|
assert_eq!(v["items"][0]["authorUsername"], "user1");
|
||||||
assert_eq!(v["thoughts"][0]["content"], "A thought from user1");
|
assert_eq!(v["items"][0]["content"], "A thought from user1");
|
||||||
|
|
||||||
// 3. user1 follows user2
|
// 3. user1 follows user2
|
||||||
make_jwt_request(
|
make_jwt_request(
|
||||||
@@ -78,9 +81,193 @@ async fn test_feed_and_user_thoughts() {
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v["thoughts"].as_array().unwrap().len(), 2);
|
assert_eq!(v["items"].as_array().unwrap().len(), 2);
|
||||||
assert_eq!(v["thoughts"][0]["author_username"], "user2");
|
assert_eq!(v["items"][0]["authorUsername"], "user2");
|
||||||
assert_eq!(v["thoughts"][0]["content"], "user2 was here");
|
assert_eq!(v["items"][0]["content"], "user2 was here");
|
||||||
assert_eq!(v["thoughts"][1]["author_username"], "user1");
|
assert_eq!(v["items"][1]["authorUsername"], "user1");
|
||||||
assert_eq!(v["thoughts"][1]["content"], "A thought from user1");
|
assert_eq!(v["items"][1]["content"], "A thought from user1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_feed_strict_chronological_order() {
|
||||||
|
let app = setup().await;
|
||||||
|
create_user_with_password(&app.db, "user1", "password123", "u1@e.com").await;
|
||||||
|
create_user_with_password(&app.db, "user2", "password123", "u2@e.com").await;
|
||||||
|
create_user_with_password(&app.db, "user3", "password123", "u3@e.com").await;
|
||||||
|
|
||||||
|
let token1 = super::main::login_user(app.router.clone(), "user1", "password123").await;
|
||||||
|
let token2 = super::main::login_user(app.router.clone(), "user2", "password123").await;
|
||||||
|
let token3 = super::main::login_user(app.router.clone(), "user3", "password123").await;
|
||||||
|
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user2/follow",
|
||||||
|
"POST",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user3/follow",
|
||||||
|
"POST",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let body_t1 = json!({ "content": "Thought 1 from user2" }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(body_t1),
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
sleep(Duration::from_millis(10)).await;
|
||||||
|
|
||||||
|
let body_t2 = json!({ "content": "Thought 2 from user3" }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(body_t2),
|
||||||
|
&token3,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
sleep(Duration::from_millis(10)).await;
|
||||||
|
|
||||||
|
let body_t3 = json!({ "content": "Thought 3 from user2" }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(body_t3),
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token1).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let thoughts = v["items"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
thoughts.len(),
|
||||||
|
3,
|
||||||
|
"Feed should contain 3 thoughts from followed users and self"
|
||||||
|
);
|
||||||
|
assert_eq!(thoughts[0]["content"], "Thought 3 from user2");
|
||||||
|
assert_eq!(thoughts[1]["content"], "Thought 2 from user3");
|
||||||
|
assert_eq!(thoughts[2]["content"], "Thought 1 from user2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_feed_pagination() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
// 1. Setup users
|
||||||
|
create_user_with_password(&app.db, "user1", "password123", "u1@e.com").await;
|
||||||
|
create_user_with_password(&app.db, "user2", "password123", "u2@e.com").await;
|
||||||
|
create_user_with_password(&app.db, "user3", "password123", "u3@e.com").await;
|
||||||
|
|
||||||
|
let token1 = super::main::login_user(app.router.clone(), "user1", "password123").await;
|
||||||
|
let token2 = super::main::login_user(app.router.clone(), "user2", "password123").await;
|
||||||
|
let token3 = super::main::login_user(app.router.clone(), "user3", "password123").await;
|
||||||
|
|
||||||
|
// 2. user1 follows user2 and user3
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user2/follow",
|
||||||
|
"POST",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user3/follow",
|
||||||
|
"POST",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 3. Create 25 thoughts from the followed users to test pagination
|
||||||
|
// user1's feed also includes their own thoughts.
|
||||||
|
let mut last_thought_content = String::new();
|
||||||
|
for i in 0..25 {
|
||||||
|
let content = format!("Thought number {}", i);
|
||||||
|
// Alternate who posts to mix up the feed
|
||||||
|
let token_to_use = match i % 3 {
|
||||||
|
0 => &token2,
|
||||||
|
1 => &token3,
|
||||||
|
_ => &token1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = json!({ "content": &content }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(body),
|
||||||
|
token_to_use,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if i == 24 {
|
||||||
|
last_thought_content = content;
|
||||||
|
}
|
||||||
|
// Small delay to ensure created_at timestamps are distinct
|
||||||
|
sleep(Duration::from_millis(5)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Request the first page (default size 20)
|
||||||
|
let response_p1 = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/feed?page=1&page_size=20",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response_p1.status(), StatusCode::OK);
|
||||||
|
let body_p1 = response_p1.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v_p1: Value = serde_json::from_slice(&body_p1).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
v_p1["items"].as_array().unwrap().len(),
|
||||||
|
20,
|
||||||
|
"First page should have 20 items"
|
||||||
|
);
|
||||||
|
assert_eq!(v_p1["page"], 1);
|
||||||
|
assert_eq!(v_p1["pageSize"], 20);
|
||||||
|
assert_eq!(v_p1["totalPages"], 2);
|
||||||
|
assert_eq!(v_p1["totalItems"], 25);
|
||||||
|
|
||||||
|
// Verify the newest thought is first on the first page
|
||||||
|
assert_eq!(v_p1["items"][0]["content"], last_thought_content);
|
||||||
|
|
||||||
|
// 5. Request the second page
|
||||||
|
let response_p2 = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/feed?page=2&page_size=20",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response_p2.status(), StatusCode::OK);
|
||||||
|
let body_p2 = response_p2.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v_p2: Value = serde_json::from_slice(&body_p2).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
v_p2["items"].as_array().unwrap().len(),
|
||||||
|
5,
|
||||||
|
"Second page should have the remaining 5 items"
|
||||||
|
);
|
||||||
|
assert_eq!(v_p2["page"], 2);
|
||||||
}
|
}
|
||||||
|
@@ -1,14 +1,18 @@
|
|||||||
|
use crate::api::main::login_user;
|
||||||
|
|
||||||
use super::main::{create_user_with_password, setup};
|
use super::main::{create_user_with_password, setup};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use utils::testing::make_jwt_request;
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::Value;
|
||||||
|
use utils::testing::{make_get_request, make_jwt_request};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_follow_endpoints() {
|
async fn test_follow_endpoints() {
|
||||||
std::env::set_var("AUTH_SECRET", "test-secret");
|
std::env::set_var("AUTH_SECRET", "test-secret");
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
|
|
||||||
create_user_with_password(&app.db, "user1", "password1").await;
|
create_user_with_password(&app.db, "user1", "password1", "user1@example.com").await;
|
||||||
create_user_with_password(&app.db, "user2", "password2").await;
|
create_user_with_password(&app.db, "user2", "password2", "user2@example.com").await;
|
||||||
|
|
||||||
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
||||||
|
|
||||||
@@ -67,3 +71,92 @@ async fn test_follow_endpoints() {
|
|||||||
.await;
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_follow_lists() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
let user_a = create_user_with_password(&app.db, "userA", "password123", "a@a.com").await;
|
||||||
|
let user_b = create_user_with_password(&app.db, "userB", "password123", "b@b.com").await;
|
||||||
|
let user_c = create_user_with_password(&app.db, "userC", "password123", "c@c.com").await;
|
||||||
|
|
||||||
|
// A follows B, C follows A
|
||||||
|
app::persistence::follow::follow_user(&app.db, user_a.id, user_b.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
app::persistence::follow::follow_user(&app.db, user_c.id, user_a.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 1. Check user A's lists
|
||||||
|
let response_following =
|
||||||
|
make_get_request(app.router.clone(), "/users/userA/following", None).await;
|
||||||
|
let body_following = response_following
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body_following).unwrap();
|
||||||
|
assert_eq!(v["users"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["users"][0]["username"], "userB");
|
||||||
|
|
||||||
|
let response_followers =
|
||||||
|
make_get_request(app.router.clone(), "/users/userA/followers", None).await;
|
||||||
|
let body_followers = response_followers
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body_followers).unwrap();
|
||||||
|
assert_eq!(v["users"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["users"][0]["username"], "userC");
|
||||||
|
|
||||||
|
// 2. Check user A's /me endpoint
|
||||||
|
let jwt_a = login_user(app.router.clone(), "userA", "password123").await;
|
||||||
|
let response_me = make_jwt_request(app.router.clone(), "/users/me", "GET", None, &jwt_a).await;
|
||||||
|
let body_me = response_me.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body_me).unwrap();
|
||||||
|
assert_eq!(v["username"], "userA");
|
||||||
|
assert_eq!(v["following"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["following"][0]["username"], "userB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_friends_list() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
let user_a = create_user_with_password(&app.db, "userA", "password123", "a@a.com").await;
|
||||||
|
let user_b = create_user_with_password(&app.db, "userB", "password123", "b@b.com").await;
|
||||||
|
let user_c = create_user_with_password(&app.db, "userC", "password123", "c@c.com").await;
|
||||||
|
|
||||||
|
// --- Create relationships ---
|
||||||
|
// A and B are friends (reciprocal follow)
|
||||||
|
app::persistence::follow::follow_user(&app.db, user_a.id, user_b.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
app::persistence::follow::follow_user(&app.db, user_b.id, user_a.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// A follows C, but C does not follow A back
|
||||||
|
app::persistence::follow::follow_user(&app.db, user_a.id, user_c.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// --- Test as user_a ---
|
||||||
|
let jwt_a = login_user(app.router.clone(), "userA", "password123").await;
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/friends", "GET", None, &jwt_a).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let friends_list = v["users"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(friends_list.len(), 1, "User A should only have one friend");
|
||||||
|
assert_eq!(
|
||||||
|
friends_list[0]["username"], "userB",
|
||||||
|
"User B should be in User A's friend list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,8 +1,7 @@
|
|||||||
use api::setup_router;
|
use api::setup_router;
|
||||||
use app::persistence::user::create_user;
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use models::params::{auth::RegisterParams, user::CreateUserParams};
|
use models::{domains::user, params::auth::RegisterParams};
|
||||||
use sea_orm::DatabaseConnection;
|
use sea_orm::DatabaseConnection;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use utils::testing::{make_post_request, setup_test_db};
|
use utils::testing::{make_post_request, setup_test_db};
|
||||||
@@ -13,39 +12,42 @@ pub struct TestApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn setup() -> TestApp {
|
pub async fn setup() -> TestApp {
|
||||||
std::env::set_var("DATABASE_URL", "sqlite::memory:");
|
std::env::set_var(
|
||||||
|
"MANAGEMENT_DATABASE_URL",
|
||||||
|
"postgres://postgres:postgres@localhost:5434/postgres",
|
||||||
|
);
|
||||||
|
std::env::set_var(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgres://postgres:postgres@localhost:5434/postgres",
|
||||||
|
);
|
||||||
std::env::set_var("AUTH_SECRET", "test_secret");
|
std::env::set_var("AUTH_SECRET", "test_secret");
|
||||||
std::env::set_var("BASE_URL", "http://localhost:3000");
|
std::env::set_var("BASE_URL", "http://localhost:3000");
|
||||||
std::env::set_var("HOST", "localhost");
|
std::env::set_var("HOST", "localhost");
|
||||||
std::env::set_var("PORT", "3000");
|
std::env::set_var("PORT", "3000");
|
||||||
std::env::set_var("LOG_LEVEL", "debug");
|
std::env::set_var("LOG_LEVEL", "debug");
|
||||||
|
|
||||||
let db = setup_test_db("sqlite::memory:")
|
let db = setup_test_db().await.expect("Failed to set up test db");
|
||||||
.await
|
|
||||||
.expect("Failed to set up test db");
|
let db = db.clone();
|
||||||
|
|
||||||
let router = setup_router(db.clone(), &app::config::Config::from_env());
|
let router = setup_router(db.clone(), &app::config::Config::from_env());
|
||||||
TestApp { router, db }
|
TestApp { router, db }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create users for tests
|
pub async fn create_user_with_password(
|
||||||
pub async fn create_test_user(db: &DatabaseConnection, username: &str) {
|
db: &DatabaseConnection,
|
||||||
let params = CreateUserParams {
|
username: &str,
|
||||||
username: username.to_string(),
|
password: &str,
|
||||||
password: "password".to_string(),
|
email: &str,
|
||||||
};
|
) -> user::Model {
|
||||||
create_user(db, params)
|
|
||||||
.await
|
|
||||||
.expect("Failed to create test user");
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_user_with_password(db: &DatabaseConnection, username: &str, password: &str) {
|
|
||||||
let params = RegisterParams {
|
let params = RegisterParams {
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
password: password.to_string(),
|
password: password.to_string(),
|
||||||
|
email: email.to_string(),
|
||||||
};
|
};
|
||||||
app::persistence::auth::register_user(db, params)
|
app::persistence::auth::register_user(db, params)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create test user with password");
|
.expect("Failed to create test user with password")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login_user(router: Router, username: &str, password: &str) -> String {
|
pub async fn login_user(router: Router, username: &str, password: &str) -> String {
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
mod activitypub;
|
mod api_key;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod feed;
|
mod feed;
|
||||||
mod follow;
|
mod follow;
|
||||||
mod main;
|
mod main;
|
||||||
|
mod search;
|
||||||
|
mod tag;
|
||||||
mod thought;
|
mod thought;
|
||||||
mod user;
|
mod user;
|
||||||
|
198
thoughts-backend/tests/api/search.rs
Normal file
198
thoughts-backend/tests/api/search.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
use crate::api::main::{create_user_with_password, login_user, setup};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use utils::testing::{make_get_request, make_jwt_request};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_search_all() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
// 1. Setup users and data
|
||||||
|
let user1 =
|
||||||
|
create_user_with_password(&app.db, "search_user1", "password123", "s1@test.com").await;
|
||||||
|
let user2 =
|
||||||
|
create_user_with_password(&app.db, "search_user2", "password123", "s2@test.com").await;
|
||||||
|
let _user3 =
|
||||||
|
create_user_with_password(&app.db, "stranger_user", "password123", "s3@test.com").await;
|
||||||
|
|
||||||
|
// Make user1 and user2 friends
|
||||||
|
app::persistence::follow::follow_user(&app.db, user1.id, user2.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
app::persistence::follow::follow_user(&app.db, user2.id, user1.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let token1 = login_user(app.router.clone(), "search_user1", "password123").await;
|
||||||
|
let token2 = login_user(app.router.clone(), "search_user2", "password123").await;
|
||||||
|
let token3 = login_user(app.router.clone(), "stranger_user", "password123").await;
|
||||||
|
|
||||||
|
// User1 posts thoughts with different visibilities
|
||||||
|
let thought_public =
|
||||||
|
json!({ "content": "A very public thought about Rust.", "visibility": "Public" })
|
||||||
|
.to_string();
|
||||||
|
let thought_friends =
|
||||||
|
json!({ "content": "A friendly thought, just for pals.", "visibility": "FriendsOnly" })
|
||||||
|
.to_string();
|
||||||
|
let thought_private =
|
||||||
|
json!({ "content": "A private thought, for my eyes only.", "visibility": "Private" })
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(thought_public),
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(thought_friends),
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(thought_private),
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 2. Run search tests
|
||||||
|
|
||||||
|
// -- User Search --
|
||||||
|
let response = make_get_request(app.router.clone(), "/search?q=search_user1", None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v["users"]["users"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["users"]["users"][0]["username"], "search_user1");
|
||||||
|
|
||||||
|
// -- Thought Search (Public) --
|
||||||
|
let response = make_get_request(app.router.clone(), "/search?q=public", None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"Guest should find public thought"
|
||||||
|
);
|
||||||
|
assert!(v["thoughts"]["thoughts"][0]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("public"));
|
||||||
|
|
||||||
|
// -- Thought Search (FriendsOnly) --
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=friendly",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"Author should find friends thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=friendly",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"Friend should find friends thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=friendly",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token3,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
0,
|
||||||
|
"Stranger should NOT find friends thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_get_request(app.router.clone(), "/search?q=friendly", None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
0,
|
||||||
|
"Guest should NOT find friends thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
// -- Thought Search (Private) --
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=private",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"Author should find private thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=private",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
0,
|
||||||
|
"Friend should NOT find private thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_get_request(app.router.clone(), "/search?q=private", None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
0,
|
||||||
|
"Guest should NOT find private thought"
|
||||||
|
);
|
||||||
|
}
|
91
thoughts-backend/tests/api/tag.rs
Normal file
91
thoughts-backend/tests/api/tag.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
use crate::api::main::{create_user_with_password, login_user, setup, TestApp};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use utils::testing::{make_get_request, make_jwt_request};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_hashtag_flow() {
|
||||||
|
let app = setup().await;
|
||||||
|
let user =
|
||||||
|
create_user_with_password(&app.db, "taguser", "password123", "taguser@example.com").await;
|
||||||
|
let token = login_user(app.router.clone(), "taguser", "password123").await;
|
||||||
|
|
||||||
|
// 1. Post a thought with hashtags
|
||||||
|
let body = json!({ "content": "Hello #world this is a post about #RustLang" }).to_string();
|
||||||
|
let response =
|
||||||
|
make_jwt_request(app.router.clone(), "/thoughts", "POST", Some(body), &token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let thought_json: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||||
|
let thought_id = thought_json["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
// 2. Post another thought
|
||||||
|
let body2 = json!({ "content": "Another post about the #rustlang ecosystem" }).to_string();
|
||||||
|
make_jwt_request(app.router.clone(), "/thoughts", "POST", Some(body2), &token).await;
|
||||||
|
|
||||||
|
// 3. Fetch thoughts by tag "rustlang"
|
||||||
|
let response = make_get_request(app.router.clone(), "/tags/rustlang", Some(user.id)).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||||
|
|
||||||
|
let thoughts = v["thoughts"].as_array().unwrap();
|
||||||
|
assert_eq!(thoughts.len(), 2);
|
||||||
|
// Note: The most recent post appears first
|
||||||
|
assert_eq!(
|
||||||
|
thoughts[0]["content"],
|
||||||
|
"Another post about the #rustlang ecosystem"
|
||||||
|
);
|
||||||
|
assert_eq!(thoughts[1]["id"], thought_id);
|
||||||
|
|
||||||
|
// 4. Fetch thoughts by tag "world"
|
||||||
|
let response = make_get_request(app.router.clone(), "/tags/world", Some(user.id)).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||||
|
|
||||||
|
let thoughts = v["thoughts"].as_array().unwrap();
|
||||||
|
assert_eq!(thoughts.len(), 1);
|
||||||
|
assert_eq!(thoughts[0]["id"], thought_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_popular_tags() {
|
||||||
|
let app = setup().await;
|
||||||
|
let _ = create_user_with_password(&app.db, "poptag_user", "password123", "poptag@example.com")
|
||||||
|
.await;
|
||||||
|
let token = login_user(app.router.clone(), "poptag_user", "password123").await;
|
||||||
|
|
||||||
|
// Helper async function to post a thought
|
||||||
|
async fn post_thought(app: &TestApp, token: &str, content: &str) {
|
||||||
|
let body = json!({ "content": content }).to_string();
|
||||||
|
let response =
|
||||||
|
make_jwt_request(app.router.clone(), "/thoughts", "POST", Some(body), token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Post thoughts to create tag usage data
|
||||||
|
// Expected counts: rust (3), web (2), axum (2), testing (1)
|
||||||
|
post_thought(&app, &token, "My first post about #rust and the #web").await;
|
||||||
|
post_thought(&app, &token, "Another post about #rust and #axum").await;
|
||||||
|
post_thought(&app, &token, "I'm really enjoying #rust lately").await;
|
||||||
|
post_thought(&app, &token, "Let's talk about #axum and the #web").await;
|
||||||
|
post_thought(&app, &token, "Don't forget about #testing").await;
|
||||||
|
|
||||||
|
// 2. Fetch the popular tags
|
||||||
|
let response = make_get_request(app.router.clone(), "/tags/popular", None).await;
|
||||||
|
println!("Response: {:?}", response);
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Vec<String> = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
// 3. Assert the results
|
||||||
|
assert_eq!(v.len(), 4, "Should return the 4 unique tags used");
|
||||||
|
assert_eq!(
|
||||||
|
v,
|
||||||
|
vec!["rust", "axum", "web", "testing"],
|
||||||
|
"Tags should be ordered by popularity, then alphabetically"
|
||||||
|
);
|
||||||
|
}
|
@@ -1,41 +1,321 @@
|
|||||||
use super::main::{create_test_user, setup};
|
use crate::api::main::{create_user_with_password, login_user};
|
||||||
use axum::http::StatusCode;
|
|
||||||
|
use super::main::setup;
|
||||||
|
use app::persistence::follow;
|
||||||
|
use axum::{http::StatusCode, Router};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use serde_json::json;
|
use sea_orm::prelude::Uuid;
|
||||||
use utils::testing::{make_delete_request, make_post_request};
|
use serde_json::{json, Value};
|
||||||
|
use utils::testing::{make_delete_request, make_get_request, make_jwt_request, make_post_request};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_thought_endpoints() {
|
async fn test_thought_endpoints() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
create_test_user(&app.db, "user1").await; // AuthUser is ID 1
|
let user1 =
|
||||||
create_test_user(&app.db, "user2").await; // Other user is ID 2
|
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await; // AuthUser is ID 1
|
||||||
|
let _user2 =
|
||||||
|
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await; // Other user is ID 2
|
||||||
|
|
||||||
// 1. Post a new thought as user 1
|
// 1. Post a new thought as user 1
|
||||||
let body = json!({ "content": "My first thought!" }).to_string();
|
let body = json!({ "content": "My first thought!" }).to_string();
|
||||||
let response = make_post_request(app.router.clone(), "/thoughts", body, Some(1)).await;
|
let response = make_post_request(app.router.clone(), "/thoughts", body, Some(user1.id)).await;
|
||||||
assert_eq!(response.status(), StatusCode::CREATED);
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v["content"], "My first thought!");
|
assert_eq!(v["content"], "My first thought!");
|
||||||
assert_eq!(v["author_username"], "user1");
|
assert_eq!(v["authorUsername"], "user1");
|
||||||
let thought_id = v["id"].as_i64().unwrap();
|
let thought_id = v["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
// 2. Post a thought with invalid content
|
// 2. Post a thought with invalid content
|
||||||
let body = json!({ "content": "" }).to_string(); // Too short
|
let body = json!({ "content": "" }).to_string(); // Too short
|
||||||
let response = make_post_request(app.router.clone(), "/thoughts", body, Some(1)).await;
|
let response = make_post_request(app.router.clone(), "/thoughts", body, Some(user1.id)).await;
|
||||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
// 3. Attempt to delete another user's thought (user1 tries to delete a non-existent thought, but let's pretend it's user2's)
|
// 3. Attempt to delete another user's thought (user1 tries to delete a non-existent thought, but let's pretend it's user2's)
|
||||||
let response =
|
let response = make_delete_request(
|
||||||
make_delete_request(app.router.clone(), &format!("/thoughts/999"), Some(1)).await;
|
app.router.clone(),
|
||||||
|
&format!("/thoughts/{}", Uuid::new_v4()),
|
||||||
|
Some(user1.id),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
// 4. Delete the thought created in step 1
|
// 4. Delete the thought created in step 1
|
||||||
let response = make_delete_request(
|
let response = make_delete_request(
|
||||||
app.router.clone(),
|
app.router.clone(),
|
||||||
&format!("/thoughts/{}", thought_id),
|
&format!("/thoughts/{}", thought_id),
|
||||||
Some(1),
|
Some(user1.id),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_thought_replies() {
|
||||||
|
let app = setup().await;
|
||||||
|
let user1 =
|
||||||
|
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await;
|
||||||
|
let user2 =
|
||||||
|
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await;
|
||||||
|
|
||||||
|
// 1. User 1 posts an original thought
|
||||||
|
let body = json!({ "content": "This is the original post!" }).to_string();
|
||||||
|
let response = make_post_request(app.router.clone(), "/thoughts", body, Some(user1.id)).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let original_thought: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let original_thought_id = original_thought["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
// 2. User 2 replies to the original thought
|
||||||
|
let reply_body = json!({
|
||||||
|
"content": "This is a reply.",
|
||||||
|
"replyToId": original_thought_id
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response =
|
||||||
|
make_post_request(app.router.clone(), "/thoughts", reply_body, Some(user2.id)).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let reply_thought: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
// 3. Verify the reply is linked correctly
|
||||||
|
assert_eq!(reply_thought["replyToId"], original_thought_id);
|
||||||
|
assert_eq!(reply_thought["authorUsername"], "user2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_thought_visibility() {
|
||||||
|
let app = setup().await;
|
||||||
|
let author = create_user_with_password(&app.db, "author", "password123", "a@a.com").await;
|
||||||
|
let friend = create_user_with_password(&app.db, "friend", "password123", "f@f.com").await;
|
||||||
|
let _stranger = create_user_with_password(&app.db, "stranger", "password123", "s@s.com").await;
|
||||||
|
|
||||||
|
// Make author and friend follow each other
|
||||||
|
follow::follow_user(&app.db, author.id, friend.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
follow::follow_user(&app.db, friend.id, author.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let author_jwt = login_user(app.router.clone(), "author", "password123").await;
|
||||||
|
let friend_jwt = login_user(app.router.clone(), "friend", "password123").await;
|
||||||
|
let stranger_jwt = login_user(app.router.clone(), "stranger", "password123").await;
|
||||||
|
|
||||||
|
// Author posts one of each visibility
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(json!({"content": "public", "visibility": "Public"}).to_string()),
|
||||||
|
&author_jwt,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(json!({"content": "friends", "visibility": "FriendsOnly"}).to_string()),
|
||||||
|
&author_jwt,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(json!({"content": "private", "visibility": "Private"}).to_string()),
|
||||||
|
&author_jwt,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Helper to get thoughts and count them
|
||||||
|
async fn get_thought_count(router: Router, jwt: Option<&str>) -> usize {
|
||||||
|
let response = if let Some(token) = jwt {
|
||||||
|
make_jwt_request(router, "/users/author/thoughts", "GET", None, token).await
|
||||||
|
} else {
|
||||||
|
make_get_request(router, "/users/author/thoughts", None).await
|
||||||
|
};
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
v["thoughts"].as_array().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
assert_eq!(
|
||||||
|
get_thought_count(app.router.clone(), Some(&author_jwt)).await,
|
||||||
|
3,
|
||||||
|
"Author should see all their posts"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_thought_count(app.router.clone(), Some(&friend_jwt)).await,
|
||||||
|
2,
|
||||||
|
"Friend should see public and friends_only posts"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_thought_count(app.router.clone(), Some(&stranger_jwt)).await,
|
||||||
|
1,
|
||||||
|
"Stranger should see only public posts"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_thought_count(app.router.clone(), None).await,
|
||||||
|
1,
|
||||||
|
"Unauthenticated guest should see only public posts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_thought_and_get_id(
|
||||||
|
router: &Router,
|
||||||
|
content: &str,
|
||||||
|
visibility: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> String {
|
||||||
|
let body = json!({ "content": content, "visibility": visibility }).to_string();
|
||||||
|
let response = make_jwt_request(router.clone(), "/thoughts", "POST", Some(body), token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
v["id"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_thought_by_id_visibility() {
|
||||||
|
let app = setup().await;
|
||||||
|
let author = create_user_with_password(&app.db, "author", "password123", "a@a.com").await;
|
||||||
|
let friend = create_user_with_password(&app.db, "friend", "password123", "f@f.com").await;
|
||||||
|
let _stranger = create_user_with_password(&app.db, "stranger", "password123", "s@s.com").await;
|
||||||
|
|
||||||
|
// Make author and friend follow each other
|
||||||
|
follow::follow_user(&app.db, author.id, friend.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
follow::follow_user(&app.db, friend.id, author.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let author_jwt = login_user(app.router.clone(), "author", "password123").await;
|
||||||
|
let friend_jwt = login_user(app.router.clone(), "friend", "password123").await;
|
||||||
|
let stranger_jwt = login_user(app.router.clone(), "stranger", "password123").await;
|
||||||
|
|
||||||
|
// Author posts one of each visibility
|
||||||
|
let public_id = post_thought_and_get_id(&app.router, "public", "Public", &author_jwt).await;
|
||||||
|
let friends_id =
|
||||||
|
post_thought_and_get_id(&app.router, "friends", "FriendsOnly", &author_jwt).await;
|
||||||
|
let private_id = post_thought_and_get_id(&app.router, "private", "Private", &author_jwt).await;
|
||||||
|
|
||||||
|
// --- Test Assertions ---
|
||||||
|
|
||||||
|
// 1. Public thought
|
||||||
|
let public_url = format!("/thoughts/{}", public_id);
|
||||||
|
assert_eq!(
|
||||||
|
make_get_request(app.router.clone(), &public_url, None)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"Guest should see public thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Friends-only thought
|
||||||
|
let friends_url = format!("/thoughts/{}", friends_id);
|
||||||
|
assert_eq!(
|
||||||
|
make_jwt_request(app.router.clone(), &friends_url, "GET", None, &friend_jwt)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"Friend should see friends-only thought"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
make_jwt_request(app.router.clone(), &friends_url, "GET", None, &stranger_jwt)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Stranger should NOT see friends-only thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Private thought
|
||||||
|
let private_url = format!("/thoughts/{}", private_id);
|
||||||
|
assert_eq!(
|
||||||
|
make_jwt_request(app.router.clone(), &private_url, "GET", None, &author_jwt)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"Author should see their private thought"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
make_jwt_request(app.router.clone(), &private_url, "GET", None, &friend_jwt)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Friend should NOT see private thought"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_thought_thread() {
|
||||||
|
let app = setup().await;
|
||||||
|
let _user1 =
|
||||||
|
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await;
|
||||||
|
let _user2 =
|
||||||
|
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await;
|
||||||
|
let user3 =
|
||||||
|
create_user_with_password(&app.db, "user3", "password123", "user3@example.com").await;
|
||||||
|
|
||||||
|
let token1 = login_user(app.router.clone(), "user1", "password123").await;
|
||||||
|
let token2 = login_user(app.router.clone(), "user2", "password123").await;
|
||||||
|
|
||||||
|
// 1. user1 posts a root thought
|
||||||
|
let root_id = post_thought_and_get_id(&app.router, "Root thought", "Public", &token1).await;
|
||||||
|
|
||||||
|
// 2. user2 replies to the root thought
|
||||||
|
let reply1_body = json!({ "content": "First reply", "replyToId": root_id }).to_string();
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(reply1_body),
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let reply1: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let reply1_id = reply1["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// 3. user1 replies to user2's reply
|
||||||
|
let reply2_body =
|
||||||
|
json!({ "content": "Reply to the reply", "replyToId": reply1_id }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(reply2_body),
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 4. Fetch the entire thread
|
||||||
|
let response = make_get_request(
|
||||||
|
app.router.clone(),
|
||||||
|
&format!("/thoughts/{}/thread", root_id),
|
||||||
|
Some(user3.id), // Fetch as a third user to test visibility
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let thread: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
// 5. Assert the structure
|
||||||
|
assert_eq!(thread["content"], "Root thought");
|
||||||
|
assert_eq!(thread["authorUsername"], "user1");
|
||||||
|
assert_eq!(thread["replies"].as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
let reply_level_1 = &thread["replies"][0];
|
||||||
|
assert_eq!(reply_level_1["content"], "First reply");
|
||||||
|
assert_eq!(reply_level_1["authorUsername"], "user2");
|
||||||
|
assert_eq!(reply_level_1["replies"].as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
let reply_level_2 = &reply_level_1["replies"][0];
|
||||||
|
assert_eq!(reply_level_2["content"], "Reply to the reply");
|
||||||
|
assert_eq!(reply_level_2["authorUsername"], "user1");
|
||||||
|
assert!(reply_level_2["replies"].as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
@@ -1,32 +1,38 @@
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use serde_json::Value;
|
use models::domains::top_friends;
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use utils::testing::{make_get_request, make_post_request};
|
use utils::testing::{make_get_request, make_jwt_request, make_post_request};
|
||||||
|
|
||||||
use crate::api::main::setup;
|
use crate::api::main::{create_user_with_password, login_user, setup};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_post_users() {
|
async fn test_post_users() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
|
|
||||||
let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
|
let body = r#"{"username": "test", "email": "test@example.com", "password": "password123"}"#
|
||||||
|
.to_owned();
|
||||||
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::CREATED);
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
assert_eq!(&body[..], br#"{"id":1,"username":"test"}"#);
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["username"], "test");
|
||||||
|
assert!(v["displayName"].is_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
pub(super) async fn test_post_users_error() {
|
pub(super) async fn test_post_users_error() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
|
|
||||||
let body = r#"{"username": "1", "password": "password123"}"#.to_owned();
|
let body =
|
||||||
|
r#"{"username": "1", "email": "test@example.com", "password": "password123"}"#.to_owned();
|
||||||
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
||||||
|
|
||||||
println!("{:?}", response);
|
|
||||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
@@ -39,12 +45,268 @@ pub(super) async fn test_post_users_error() {
|
|||||||
pub async fn test_get_users() {
|
pub async fn test_get_users() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
|
|
||||||
let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
|
let body = r#"{"username": "test", "email": "test@example.com", "password": "password123"}"#
|
||||||
|
.to_owned();
|
||||||
make_post_request(app.router.clone(), "/auth/register", body, None).await;
|
make_post_request(app.router.clone(), "/auth/register", body, None).await;
|
||||||
|
|
||||||
let response = make_get_request(app.router, "/users", None).await;
|
let response = make_get_request(app.router, "/users", None).await;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
assert_eq!(&body[..], br#"{"users":[{"id":1,"username":"test"}]}"#);
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
assert!(v["users"].is_array());
|
||||||
|
let users_array = v["users"].as_array().unwrap();
|
||||||
|
assert_eq!(users_array.len(), 1);
|
||||||
|
assert_eq!(users_array[0]["username"], "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_me_endpoints() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
// 1. Register a new user
|
||||||
|
let register_body = json!({
|
||||||
|
"username": "me_user",
|
||||||
|
"email": "me_user@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response =
|
||||||
|
make_post_request(app.router.clone(), "/auth/register", register_body, None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
// 2. Log in to get a token
|
||||||
|
let token = login_user(app.router.clone(), "me_user", "password123").await;
|
||||||
|
|
||||||
|
// 3. GET /users/me to fetch initial profile
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/users/me", "GET", None, &token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v["username"], "me_user");
|
||||||
|
assert!(v["bio"].is_null());
|
||||||
|
assert!(v["displayName"].is_string());
|
||||||
|
|
||||||
|
// 4. PUT /users/me to update the profile
|
||||||
|
let update_body = json!({
|
||||||
|
"displayName": "Me User",
|
||||||
|
"bio": "This is my updated bio.",
|
||||||
|
"avatarUrl": "https://example.com/avatar.png"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/me",
|
||||||
|
"PUT",
|
||||||
|
Some(update_body),
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v_updated: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v_updated["displayName"], "Me User");
|
||||||
|
assert_eq!(v_updated["bio"], "This is my updated bio.");
|
||||||
|
|
||||||
|
// 5. GET /users/me again to verify the update was persisted
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/users/me", "GET", None, &token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v_verify: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v_verify["displayName"], "Me User");
|
||||||
|
assert_eq!(v_verify["bio"], "This is my updated bio.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_update_me_top_friends() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
// 1. Create users for the test
|
||||||
|
let user_me =
|
||||||
|
create_user_with_password(&app.db, "me_user", "password123", "me_user@example.com").await;
|
||||||
|
let friend1 =
|
||||||
|
create_user_with_password(&app.db, "friend1", "password123", "friend1@example.com").await;
|
||||||
|
let friend2 =
|
||||||
|
create_user_with_password(&app.db, "friend2", "password123", "friend2@example.com").await;
|
||||||
|
let _friend3 =
|
||||||
|
create_user_with_password(&app.db, "friend3", "password123", "friend3@example.com").await;
|
||||||
|
|
||||||
|
// 2. Log in as "me_user"
|
||||||
|
let token = login_user(app.router.clone(), "me_user", "password123").await;
|
||||||
|
|
||||||
|
// 3. Update profile to set top friends
|
||||||
|
let update_body = json!({
|
||||||
|
"topFriends": ["friend1", "friend2"]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/me",
|
||||||
|
"PUT",
|
||||||
|
Some(update_body),
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 4. Verify the database state directly
|
||||||
|
let top_friends_list = top_friends::Entity::find()
|
||||||
|
.filter(top_friends::Column::UserId.eq(user_me.id))
|
||||||
|
.all(&app.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(top_friends_list.len(), 2);
|
||||||
|
assert_eq!(top_friends_list[0].friend_id, friend1.id);
|
||||||
|
assert_eq!(top_friends_list[0].position, 1);
|
||||||
|
assert_eq!(top_friends_list[1].friend_id, friend2.id);
|
||||||
|
assert_eq!(top_friends_list[1].position, 2);
|
||||||
|
|
||||||
|
// 5. Update again with a different list to test replacement
|
||||||
|
let update_body_2 = json!({
|
||||||
|
"topFriends": ["friend2"]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/me",
|
||||||
|
"PUT",
|
||||||
|
Some(update_body_2),
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 6. Verify the new state
|
||||||
|
let top_friends_list_2 = top_friends::Entity::find()
|
||||||
|
.filter(top_friends::Column::UserId.eq(user_me.id))
|
||||||
|
.all(&app.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(top_friends_list_2.len(), 1);
|
||||||
|
assert_eq!(top_friends_list_2[0].friend_id, friend2.id);
|
||||||
|
assert_eq!(top_friends_list_2[0].position, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_update_me_css_and_images() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
// 1. Create and log in as a user
|
||||||
|
let _ =
|
||||||
|
create_user_with_password(&app.db, "css_user", "password123", "css_user@example.com").await;
|
||||||
|
let token = login_user(app.router.clone(), "css_user", "password123").await;
|
||||||
|
|
||||||
|
// 2. Attempt to update with an invalid avatar URL
|
||||||
|
let invalid_body = json!({
|
||||||
|
"avatarUrl": "not-a-valid-url"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/me",
|
||||||
|
"PUT",
|
||||||
|
Some(invalid_body),
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
// 3. Update profile with valid URLs and custom CSS
|
||||||
|
let valid_body = json!({
|
||||||
|
"avatarUrl": "https://example.com/new-avatar.png",
|
||||||
|
"headerUrl": "https://example.com/new-header.jpg",
|
||||||
|
"customCss": "body { color: blue; }"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/me",
|
||||||
|
"PUT",
|
||||||
|
Some(valid_body),
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 4. Verify the changes were persisted by fetching the profile again
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/users/me", "GET", None, &token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["avatarUrl"], "https://example.com/new-avatar.png");
|
||||||
|
assert_eq!(v["headerUrl"], "https://example.com/new-header.jpg");
|
||||||
|
assert_eq!(v["customCss"], "body { color: blue; }");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_all_users_paginated() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
for i in 0..25 {
|
||||||
|
create_user_with_password(
|
||||||
|
&app.db,
|
||||||
|
&format!("user{}", i),
|
||||||
|
"password123",
|
||||||
|
&format!("u{}@e.com", i),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_p1 = make_get_request(app.router.clone(), "/users/all", None).await;
|
||||||
|
assert_eq!(response_p1.status(), StatusCode::OK);
|
||||||
|
let body_p1 = response_p1.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v_p1: Value = serde_json::from_slice(&body_p1).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
v_p1["items"].as_array().unwrap().len(),
|
||||||
|
20,
|
||||||
|
"First page should have 20 items"
|
||||||
|
);
|
||||||
|
assert_eq!(v_p1["page"], 1);
|
||||||
|
assert_eq!(v_p1["pageSize"], 20);
|
||||||
|
assert_eq!(v_p1["totalPages"], 2);
|
||||||
|
assert_eq!(v_p1["totalItems"], 25);
|
||||||
|
|
||||||
|
let response_p2 = make_get_request(app.router.clone(), "/users/all?page=2", None).await;
|
||||||
|
assert_eq!(response_p2.status(), StatusCode::OK);
|
||||||
|
let body_p2 = response_p2.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v_p2: Value = serde_json::from_slice(&body_p2).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
v_p2["items"].as_array().unwrap().len(),
|
||||||
|
5,
|
||||||
|
"Second page should have 5 items"
|
||||||
|
);
|
||||||
|
assert_eq!(v_p2["page"], 2);
|
||||||
|
assert_eq!(v_p2["totalPages"], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_all_users_count() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
for i in 0..25 {
|
||||||
|
create_user_with_password(
|
||||||
|
&app.db,
|
||||||
|
&format!("user{}", i),
|
||||||
|
"password123",
|
||||||
|
&format!("u{}@e.com", i),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = make_get_request(app.router.clone(), "/users/count", None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["count"], 25);
|
||||||
}
|
}
|
||||||
|
@@ -6,9 +6,15 @@ use user::test_user;
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn user_main() {
|
async fn user_main() {
|
||||||
let db = setup_test_db("sqlite::memory:")
|
std::env::set_var(
|
||||||
.await
|
"MANAGEMENT_DATABASE_URL",
|
||||||
.expect("Set up db failed!");
|
"postgres://postgres:postgres@localhost:5434/postgres",
|
||||||
|
);
|
||||||
|
std::env::set_var(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgres://postgres:postgres@localhost:5434/postgres",
|
||||||
|
);
|
||||||
|
let db = setup_test_db().await.expect("Failed to set up test db");
|
||||||
|
|
||||||
test_user(&db).await;
|
test_user(&db).await;
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
use sea_orm::{DatabaseConnection, Unchanged};
|
use sea_orm::{DatabaseConnection, TryIntoModel};
|
||||||
|
|
||||||
use app::persistence::user::create_user;
|
use app::persistence::user::create_user;
|
||||||
use models::domains::user;
|
|
||||||
use models::params::user::CreateUserParams;
|
use models::params::user::CreateUserParams;
|
||||||
|
|
||||||
pub(super) async fn test_user(db: &DatabaseConnection) {
|
pub(super) async fn test_user(db: &DatabaseConnection) {
|
||||||
@@ -9,13 +8,11 @@ pub(super) async fn test_user(db: &DatabaseConnection) {
|
|||||||
username: "test".to_string(),
|
username: "test".to_string(),
|
||||||
password: "password".to_string(),
|
password: "password".to_string(),
|
||||||
};
|
};
|
||||||
|
let user_model = create_user(db, params)
|
||||||
|
.await
|
||||||
|
.expect("Create user failed!")
|
||||||
|
.try_into_model() // Convert ActiveModel to Model for easier checks
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let user = create_user(db, params).await.expect("Create user failed!");
|
assert_eq!(user_model.username, "test");
|
||||||
let expected = user::ActiveModel {
|
|
||||||
id: Unchanged(1),
|
|
||||||
username: Unchanged("test".to_owned()),
|
|
||||||
password_hash: Unchanged(None),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert_eq!(user, expected);
|
|
||||||
}
|
}
|
||||||
|
@@ -10,7 +10,11 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
migration = { path = "../migration" }
|
migration = { path = "../migration" }
|
||||||
|
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
||||||
|
sea-orm = { version = "1.1.12", features = ["sqlx-sqlite", "sqlx-postgres"] }
|
||||||
|
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
tower = { workspace = true, features = ["util"] }
|
tower = { workspace = true, features = ["util"] }
|
||||||
sea-orm = { workspace = true, features = ["sqlx-sqlite", "sqlx-postgres"] }
|
|
||||||
|
|
||||||
|
tokio = { workspace = true }
|
||||||
|
@@ -1,22 +0,0 @@
|
|||||||
use std::process::Command;
|
|
||||||
|
|
||||||
fn touch(file_name: &str) {
|
|
||||||
if cfg!(target_os = "windows") {
|
|
||||||
Command::new("cmd")
|
|
||||||
.args(["/C", &format!("type nul >> {}", file_name)])
|
|
||||||
.output()
|
|
||||||
.expect("failed to execute touch");
|
|
||||||
} else {
|
|
||||||
Command::new("touch")
|
|
||||||
.arg(file_name)
|
|
||||||
.output()
|
|
||||||
.expect("failed to execute touch");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_dev_db(db_url: &str) {
|
|
||||||
let prefix = "sqlite://";
|
|
||||||
if let Some(file_name) = db_url.strip_prefix(prefix) {
|
|
||||||
touch(file_name);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,6 +1,4 @@
|
|||||||
mod db;
|
mod db;
|
||||||
mod file;
|
|
||||||
pub mod testing;
|
pub mod testing;
|
||||||
|
|
||||||
pub use db::migrate;
|
pub use db::migrate;
|
||||||
pub use file::create_dev_db;
|
|
||||||
|
@@ -5,8 +5,9 @@ use axum::{
|
|||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub async fn make_get_request(app: Router, url: &str, user_id: Option<i32>) -> Response {
|
pub async fn make_get_request(app: Router, url: &str, user_id: Option<Uuid>) -> Response {
|
||||||
let mut builder = Request::builder()
|
let mut builder = Request::builder()
|
||||||
.uri(url)
|
.uri(url)
|
||||||
.header("Content-Type", "application/json");
|
.header("Content-Type", "application/json");
|
||||||
@@ -24,7 +25,7 @@ pub async fn make_post_request(
|
|||||||
app: Router,
|
app: Router,
|
||||||
url: &str,
|
url: &str,
|
||||||
body: String,
|
body: String,
|
||||||
user_id: Option<i32>,
|
user_id: Option<Uuid>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let mut builder = Request::builder()
|
let mut builder = Request::builder()
|
||||||
.method("POST")
|
.method("POST")
|
||||||
@@ -40,7 +41,7 @@ pub async fn make_post_request(
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn make_delete_request(app: Router, url: &str, user_id: Option<i32>) -> Response {
|
pub async fn make_delete_request(app: Router, url: &str, user_id: Option<Uuid>) -> Response {
|
||||||
let mut builder = Request::builder()
|
let mut builder = Request::builder()
|
||||||
.method("DELETE")
|
.method("DELETE")
|
||||||
.uri(url)
|
.uri(url)
|
||||||
|
@@ -1,9 +1,27 @@
|
|||||||
use sea_orm::{Database, DatabaseConnection, DbErr};
|
use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, DbErr, Statement};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::migrate;
|
use crate::migrate;
|
||||||
|
|
||||||
pub async fn setup_test_db(db_url: &str) -> Result<DatabaseConnection, DbErr> {
|
pub async fn setup_test_db() -> Result<DatabaseConnection, DbErr> {
|
||||||
let db = Database::connect(db_url).await?;
|
let mgmt_db_url = std::env::var("MANAGEMENT_DATABASE_URL")
|
||||||
migrate(&db).await?;
|
.expect("MANAGEMENT_DATABASE_URL must be set for tests");
|
||||||
Ok(db)
|
let db_name = format!("test_db_{}", Uuid::new_v4().simple());
|
||||||
|
let (base_url, _) = mgmt_db_url
|
||||||
|
.rsplit_once('/')
|
||||||
|
.expect("MANAGEMENT_DATABASE_URL must include a database name, e.g., '/postgres'");
|
||||||
|
|
||||||
|
let db = Database::connect(&mgmt_db_url).await?;
|
||||||
|
db.execute(Statement::from_string(
|
||||||
|
DbBackend::Postgres,
|
||||||
|
format!(r#"CREATE DATABASE "{}";"#, db_name),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 2. Connect to the new test DB and run migrations
|
||||||
|
let new_db_url = format!("{}/{}", base_url, db_name);
|
||||||
|
let conn = Database::connect(&new_db_url).await?;
|
||||||
|
migrate(&conn).await?;
|
||||||
|
|
||||||
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
@@ -1,30 +1,29 @@
|
|||||||
FROM oven/bun:1 AS base
|
FROM node:22-slim AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS install
|
ARG NEXT_PUBLIC_API_URL
|
||||||
RUN mkdir -p /temp/dev
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
COPY package.json bun.lock /temp/dev/
|
|
||||||
RUN cd /temp/dev && bun install --frozen-lockfile
|
|
||||||
|
|
||||||
RUN mkdir -p /temp/prod
|
# Install dependencies with Bun for speed
|
||||||
COPY package.json bun.lock /temp/prod/
|
COPY --chown=node:node package.json bun.lock ./
|
||||||
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
RUN npm install -g bun
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
FROM base AS prerelease
|
# Copy the rest of the app and build with Node's Next.js runtime
|
||||||
COPY --from=install /temp/dev/node_modules node_modules
|
COPY --chown=node:node . .
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
FROM base AS release
|
FROM node:22-slim AS release
|
||||||
|
|
||||||
COPY --from=prerelease /app/public ./public
|
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=prerelease /app/.next/standalone ./
|
|
||||||
COPY --from=prerelease /app/.next/static ./.next/static
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
USER bun
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
CMD ["bun", "run", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|
||||||
|
12
thoughts-frontend/app/(auth)/layout.tsx
Normal file
12
thoughts-frontend/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// app/(auth)/layout.tsx
|
||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user