Compare commits
47 Commits
5f4e9bdfa7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c6f99fff47 | |||
| 96b391ff2d | |||
| 0e13f90834 | |||
| 6f40921576 | |||
| 44fe5a519b | |||
| 70f2f7d63d | |||
| 10a4398e81 | |||
| e386f44ee2 | |||
| 293102e5f2 | |||
| 934858bebf | |||
| a40da90ef9 | |||
| c32a7cad2f | |||
| 0207cde234 | |||
| 2dd384f7be | |||
| 0782a9fe6d | |||
| 1dca8b3b78 | |||
| 49c1675e01 | |||
| bcd750b905 | |||
| c8729d38bc | |||
| c3017a255f | |||
| c9c1a739fd | |||
| 45c00b2dee | |||
| e582b269f1 | |||
| 0665772079 | |||
| 1c22700769 | |||
| e4080dcbc7 | |||
| 939931acad | |||
| 9a0c26e5f6 | |||
| ab32e8826e | |||
| eeb4d9f902 | |||
| c2487ec0b6 | |||
| f5044fb099 | |||
| 66afd932ed | |||
| 8f472a0443 | |||
| d243dce027 | |||
| 8c95eb230d | |||
| 5c99b27421 | |||
| 5b740eecd7 | |||
| a815f63927 | |||
| 7631e57f69 | |||
| cc202d6d1f | |||
| 729604f13b | |||
| 4a87ea475d | |||
| f0a443932a | |||
| 998f393ed0 | |||
| 737e934043 | |||
| dcb0fb74c5 |
@@ -1,5 +1,4 @@
|
|||||||
.codegraph
|
.codegraph
|
||||||
.claude
|
|
||||||
target
|
target
|
||||||
.git
|
.git
|
||||||
.idea
|
.idea
|
||||||
|
|||||||
+23
-120
@@ -1,120 +1,23 @@
|
|||||||
# HTTP Server
|
REPO_PREFIX_PATH=/home/zhenyi/RustroverProjects/gitks/data
|
||||||
APP_HTTP_HOST=0.0.0.0
|
GITKS_HOST=0.0.0.0
|
||||||
APP_HTTP_PORT=8000
|
GITKS_PORT=50051
|
||||||
APP_HTTP_WORKERS=4
|
GITKS_ADVERTISE_ADDR=http://gitks-node1:50051
|
||||||
APP_HTTP_JSON_LIMIT_BYTES=10485760
|
GITKS_METRICS_PORT=9100
|
||||||
|
GITKS_DISK_CACHE_ENABLED=false
|
||||||
# App
|
GITKS_DISK_CACHE_MAX_AGE=300
|
||||||
APP_URL=http://localhost:8000
|
GITKS_PACK_CACHE_ENABLED=true
|
||||||
APP_MAIN_DOMAIN=localhost
|
GITKS_PACK_CACHE_BACKPRESSURE=true
|
||||||
|
GITKS_RATE_LIMIT_MAX_CONCURRENT=100
|
||||||
# Session
|
GITKS_HOOKS_ENABLED=true
|
||||||
APP_SESSION_SECRET=change-me-to-a-secure-random-string-at-least-32-bytes
|
GITKS_HOOK_TIMEOUT=30
|
||||||
APP_SESSION_COOKIE_NAME=sid
|
GITKS_ALLOW_CUSTOM_HOOKS=true
|
||||||
APP_SESSION_COOKIE_SECURE=false
|
#GITKS_SERVER_HOOKS_DIR=/etc/gitks/hooks
|
||||||
APP_SESSION_COOKIE_HTTP_ONLY=true
|
GITKS_HOOK_CALLBACK_ADDR=http://localhost:50052
|
||||||
APP_SESSION_COOKIE_SAME_SITE=Lax
|
GITKS_ETCD_ENDPOINTS=http://localhost:2379
|
||||||
APP_SESSION_COOKIE_PATH=/
|
GITKS_CLUSTER_PORT=4697
|
||||||
APP_SESSION_COOKIE_DOMAIN=
|
GITKS_CLUSTER_COOKIE=gitks-default-cookie
|
||||||
APP_SESSION_TTL_SECS=86400
|
GITKS_LEASE_TTL=15
|
||||||
APP_SESSION_MAX_AGE_SECS=86400
|
GITKS_ETCD_CONNECT_TIMEOUT=5000
|
||||||
|
GITKS_HEALTH_CHECK_INTERVAL=1
|
||||||
# PostgreSQL
|
GITKS_MAX_HEALTH_FAILURES=10
|
||||||
DATABASE_URL=postgres://appks:appks@localhost:5432/appks
|
STORAGE_NAME=default
|
||||||
APP_DATABASE_URL=postgres://appks:appks@localhost:5432/appks
|
|
||||||
APP_DATABASE_MAX_CONNECTIONS=10
|
|
||||||
APP_DATABASE_MIN_CONNECTIONS=2
|
|
||||||
APP_DATABASE_IDLE_TIMEOUT=600
|
|
||||||
APP_DATABASE_MAX_LIFETIME=3600
|
|
||||||
APP_DATABASE_CONNECTION_TIMEOUT=8
|
|
||||||
APP_DATABASE_SCHEMA_SEARCH_PATH=public
|
|
||||||
APP_DATABASE_READ_WRITE_SPLIT=false
|
|
||||||
APP_DATABASE_RETRY_ATTEMPTS=3
|
|
||||||
APP_DATABASE_RETRY_DELAY=5
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
# Single-node mode (set APP_REDIS_CLUSTER_ENABLED=false)
|
|
||||||
APP_REDIS_URL=redis://localhost:6379/0
|
|
||||||
# Cluster mode (set APP_REDIS_CLUSTER_ENABLED=true)
|
|
||||||
APP_REDIS_CLUSTER_ENABLED=true
|
|
||||||
APP_REDIS_CLUSTER_NODES=redis://localhost:6379,redis://localhost:6380,redis://localhost:6381,redis://localhost:6382,redis://localhost:6383,redis://localhost:6384
|
|
||||||
APP_REDIS_READ_FROM_REPLICAS=false
|
|
||||||
APP_REDIS_USERNAME=
|
|
||||||
APP_REDIS_PASSWORD=
|
|
||||||
APP_REDIS_MAX_CONNECTIONS=20
|
|
||||||
APP_REDIS_MIN_CONNECTIONS=2
|
|
||||||
APP_REDIS_IDLE_TIMEOUT=300
|
|
||||||
APP_REDIS_CONNECTION_TIMEOUT=5
|
|
||||||
APP_REDIS_MAX_RETRIES=3
|
|
||||||
APP_REDIS_RETRY_DELAY_MS=100
|
|
||||||
APP_REDIS_TLS_ENABLED=false
|
|
||||||
APP_REDIS_KEY_PREFIX=appks:
|
|
||||||
|
|
||||||
# etcd
|
|
||||||
APP_ETCD_ENDPOINTS=http://localhost:2379
|
|
||||||
APP_ETCD_KEY_PREFIX=/appks/
|
|
||||||
APP_ETCD_CONNECT_TIMEOUT=5
|
|
||||||
APP_ETCD_REQUEST_TIMEOUT=10
|
|
||||||
APP_ETCD_KEEP_ALIVE_INTERVAL=10
|
|
||||||
APP_ETCD_LEASE_TTL=15
|
|
||||||
APP_ETCD_MAX_RETRIES=3
|
|
||||||
APP_ETCD_REGISTER_SELF=false
|
|
||||||
|
|
||||||
# NATS
|
|
||||||
APP_NATS_URL=nats://localhost:4222
|
|
||||||
APP_NATS_CONNECTION_TIMEOUT=5
|
|
||||||
APP_NATS_PING_INTERVAL=20
|
|
||||||
APP_NATS_RECONNECT_DELAY=2
|
|
||||||
APP_NATS_MAX_RECONNECTS=60
|
|
||||||
APP_NATS_STREAM_PREFIX=APPKS
|
|
||||||
APP_NATS_ACK_WAIT_SECS=30
|
|
||||||
APP_NATS_MAX_DELIVER=5
|
|
||||||
|
|
||||||
# S3 / MinIO
|
|
||||||
APP_S3_ENDPOINT=http://localhost:9000
|
|
||||||
APP_S3_REGION=us-east-1
|
|
||||||
APP_S3_ACCESS_KEY=admin
|
|
||||||
APP_S3_SECRET_KEY=mysecret123
|
|
||||||
APP_S3_BUCKET=appks
|
|
||||||
APP_S3_PATH_STYLE=true
|
|
||||||
APP_S3_FORCE_PATH_STYLE=true
|
|
||||||
APP_S3_PUBLIC_URL=http://localhost:9000/appks
|
|
||||||
APP_S3_MAX_CONNECTIONS=50
|
|
||||||
APP_S3_IDLE_TIMEOUT=90
|
|
||||||
APP_S3_CONNECTION_TIMEOUT=10
|
|
||||||
APP_S3_MAX_RETRIES=3
|
|
||||||
APP_S3_UPLOAD_PART_SIZE=8388608
|
|
||||||
APP_S3_MAX_UPLOAD_SIZE=104857600
|
|
||||||
APP_S3_PRESIGNED_URL_EXPIRY=3600
|
|
||||||
|
|
||||||
# LRU Cache
|
|
||||||
APP_LRU_DEFAULT_CAPACITY=1000
|
|
||||||
APP_LRU_DEFAULT_TTL_SECS=300
|
|
||||||
APP_LRU_CLEANUP_INTERVAL_SECS=60
|
|
||||||
|
|
||||||
# gRPC Server
|
|
||||||
APP_RPC_SELF_HOST=0.0.0.0
|
|
||||||
APP_RPC_SELF_PORT=50049
|
|
||||||
APP_RPC_SELF_REFLECTION=false
|
|
||||||
APP_RPC_SELF_SERVICE_NAME=appks
|
|
||||||
APP_RPC_DEFAULT_TIMEOUT_SECS=10
|
|
||||||
|
|
||||||
# AI Provider
|
|
||||||
APP_AI_PROVIDER_API_KEY=
|
|
||||||
APP_AI_PROVIDER_URL=
|
|
||||||
|
|
||||||
# Qdrant
|
|
||||||
APP_QDRANT_URL=http://localhost:6334
|
|
||||||
APP_QDRANT_COLLECTION=appks_embeddings
|
|
||||||
APP_QDRANT_VECTOR_SIZE=1536
|
|
||||||
APP_QDRANT_DISTANCE=Cosine
|
|
||||||
APP_QDRANT_MAX_CONNECTIONS=10
|
|
||||||
APP_QDRANT_IDLE_TIMEOUT=300
|
|
||||||
APP_QDRANT_CONNECTION_TIMEOUT=10
|
|
||||||
APP_QDRANT_MAX_RETRIES=3
|
|
||||||
APP_QDRANT_TLS_ENABLED=false
|
|
||||||
APP_QDRANT_SEARCH_LIMIT=10
|
|
||||||
APP_QDRANT_SCORE_THRESHOLD=0.7
|
|
||||||
|
|
||||||
# Email RPC
|
|
||||||
APP_EMAIL_RPC_ADDR=http://localhost:50050
|
|
||||||
|
|||||||
+5
-5
@@ -1,8 +1,8 @@
|
|||||||
/target
|
/target
|
||||||
.idea
|
.idea
|
||||||
.codegraph
|
.codegraph
|
||||||
.claude
|
.classpath
|
||||||
.env*
|
.project
|
||||||
!.env.example
|
.settings
|
||||||
AGENT.md
|
.DS_Store
|
||||||
CLAUDE.md
|
.env
|
||||||
|
|||||||
Generated
-10
@@ -1,10 +0,0 @@
|
|||||||
# 默认忽略的文件
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# 基于编辑器的 HTTP 客户端请求
|
|
||||||
/httpRequests/
|
|
||||||
# 已忽略包含查询文件的默认文件夹
|
|
||||||
/queries/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
@@ -1,469 +1,121 @@
|
|||||||
# AGENTS.md — 开发规范 / Development Guidelines
|
# AGENTS.md — Development Guidelines
|
||||||
|
|
||||||
> 本文件为所有 AI 编码助手(Claude Code、pi、Cursor 等)提供统一的开发指导。
|
> Unified development guidelines for all AI coding assistants (Claude Code, Cursor, etc.)
|
||||||
> This file provides unified development guidelines for all AI coding assistants.
|
|
||||||
|
|
||||||
**最后更新 / Last Updated**: 2026-06-10
|
**Last Updated**: 2026-06-11
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 目录 / Table of Contents
|
## 1. Language
|
||||||
|
|
||||||
1. [语言 / Language](#1-语言--language)
|
Always respond in **Chinese (中文)**. Code, commands, and technical terms remain in English.
|
||||||
2. [代码风格 / Code Style](#2-代码风格--code-style)
|
|
||||||
3. [禁止模式 / Forbidden Patterns](#3-禁止模式--forbidden-patterns)
|
|
||||||
4. [错误处理 / Error Handling](#4-错误处理--error-handling)
|
|
||||||
5. [安全规范 / Security](#5-安全规范--security)
|
|
||||||
6. [数据库规范 / Database](#6-数据库规范--database)
|
|
||||||
7. [API 设计规范 / API Design](#7-api-设计规范--api-design)
|
|
||||||
8. [日志与可观测性 / Logging & Observability](#8-日志与可观测性--logging--observability)
|
|
||||||
9. [性能规范 / Performance](#9-性能规范--performance)
|
|
||||||
10. [测试规范 / Testing](#10-测试规范--testing)
|
|
||||||
11. [Git 规范 / Git Workflow](#11-git-规范--git-workflow)
|
|
||||||
12. [工作流程 / Workflow](#12-工作流程--workflow)
|
|
||||||
13. [架构决策记录 / ADR](#13-架构决策记录--adr)
|
|
||||||
14. [审查清单 / Review Checklist](#14-审查清单--review-checklist)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 语言 / Language
|
## 2. Code Style
|
||||||
|
|
||||||
**Always respond in Chinese (中文).** Use the user's language for all conversations and explanations. Code, commands, and technical terms can remain in English.
|
### 2.1 Basic Principles
|
||||||
|
|
||||||
始终使用中文回复。代码、命令和技术术语可以保留英文。
|
- Follow existing project conventions
|
||||||
|
- Use meaningful variable names
|
||||||
|
- Keep functions under **50 lines**
|
||||||
|
- Maximum nesting depth: **3 levels**
|
||||||
|
- Add comments for complex logic only
|
||||||
|
|
||||||
---
|
### 2.2 Rust Best Practices
|
||||||
|
|
||||||
## 2. 代码风格 / Code Style
|
- Use `?` operator; never use `unwrap()` in non-test code
|
||||||
|
- Avoid `unsafe`; if necessary, add `// SAFETY:` comment
|
||||||
|
- Minimize `clone()`; prefer references
|
||||||
|
- No magic numbers; use named constants
|
||||||
|
- No hardcoded strings; use enums or constants
|
||||||
|
|
||||||
### 2.1 基本原则 / Basic Principles
|
### 2.3 Import Order
|
||||||
|
|
||||||
| 规则 / Rule | 说明 / Description |
|
|
||||||
|-----------|-----------------------------------------------------------------------------------------|
|
|
||||||
| 遵循现有风格 | Follow existing project conventions |
|
|
||||||
| 有意义命名 | Use meaningful variable names; avoid single-letter names except loop counters |
|
|
||||||
| 函数长度 | Keep functions under **50 lines**; split complex logic into smaller functions |
|
|
||||||
| 嵌套深度 | Maximum nesting depth: **3 levels**; use early returns to flatten logic |
|
|
||||||
| 圈复杂度 | Function cyclomatic complexity should not exceed **10** |
|
|
||||||
| 注释 | Add comments for complex logic only; prefer self-documenting code |
|
|
||||||
| 文档注释 | Public items must have `///` doc comments; private items only when logic is non-obvious |
|
|
||||||
|
|
||||||
### 2.2 Rust 最佳实践 / Rust Best Practices
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// ✅ 正确 / Correct
|
// std → third-party crates → local modules
|
||||||
fn get_user(id: i64) -> AppResult<User> {
|
|
||||||
let user = db.find_user(id).await?; // 使用 ? 传播错误
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ 错误 / Incorrect
|
|
||||||
fn get_user(id: i64) -> User {
|
|
||||||
db.find_user(id).await.unwrap() // 禁止 unwrap()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| 规则 / Rule | 说明 / Description |
|
|
||||||
|-----------|---------------------------------------------------------------------------------------------|
|
|
||||||
| 错误传播 | Use `?` operator for error propagation; never use `unwrap()` or `expect()` in non-test code |
|
|
||||||
| `unsafe` | Avoid `unsafe` blocks; if necessary, add a `// SAFETY:` comment explaining why |
|
|
||||||
| `clone()` | Minimize `clone()` usage; prefer references or `Rc`/`Arc` for shared ownership |
|
|
||||||
| 魔法数字 | No magic numbers; define named constants with `const` |
|
|
||||||
| 硬编码字符串 | No hardcoded strings for config/status; use enums or constants |
|
|
||||||
| 死代码 | Remove dead code; don't leave commented-out code blocks |
|
|
||||||
| 未完成代码 | Don't commit `unimplemented!()`, `todo!()`, or `FIXME` without a tracking issue |
|
|
||||||
|
|
||||||
### 2.3 导入规范 / Import Guidelines
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// 标准库 → 第三方 crate → 本地模块
|
|
||||||
// stdlib → third-party crates → local modules
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::FromRow;
|
|
||||||
|
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::common::Status;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 禁止模式 / Forbidden Patterns
|
## 3. Forbidden Patterns
|
||||||
|
|
||||||
以下代码模式在项目中严格禁止:
|
- `// ── xxxx ──────────` — no divider comments
|
||||||
|
- `unwrap()` / `expect()` in non-test code — use `?` instead
|
||||||
The following code patterns are strictly forbidden in this project:
|
- `panic!()` / `unreachable!()` — use error types instead
|
||||||
|
- Untracked `todo!()` — must have a corresponding issue
|
||||||
| 禁止项 / Forbidden | 说明 / Reason |
|
- Commented-out code — use Git history instead
|
||||||
|-------------------------------|------------------------------------------------|
|
- Nesting depth ≥ 4 — flatten with early return
|
||||||
| `// ── xxxx ──────────` | 禁止使用此类分隔线注释;使用 `// Section: xxx` 格式替代 |
|
- Functions > 50 lines — split into smaller functions
|
||||||
| `unwrap()` / `expect()` (非测试) | 在非测试代码中禁止使用;使用 `?` 或 `unwrap_or` 等安全替代 |
|
- Magic numbers — define named `const`
|
||||||
| `panic!()` / `unreachable!()` | 除极少数不可能到达的分支外禁止使用;使用 `AppError` 替代 |
|
- Hardcoded strings — use enums or constants
|
||||||
| 未处理的 `todo!()` | 不得提交包含 `todo!()` 的代码,除非有对应的 issue 追踪 |
|
|
||||||
| 注释掉的代码 | 不得提交被注释的代码块;使用 Git 历史追溯 |
|
|
||||||
| 过深嵌套 (≥4层) | 使用 early return、`match`、`map`/`and_then` 扁平化逻辑 |
|
|
||||||
| 过长函数 (>50行) | 拆分为更小的、职责单一的函数 |
|
|
||||||
| 魔法数字 | 使用 `const` 定义命名常量 |
|
|
||||||
| 硬编码字符串 | 使用枚举或常量定义配置值/状态值 |
|
|
||||||
| 死代码 | 删除未使用的代码、导入和变量 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 错误处理 / Error Handling
|
## 4. Error Handling
|
||||||
|
|
||||||
### 4.1 错误类型体系 / Error Type System
|
### 4.1 Principles
|
||||||
|
|
||||||
|
- Handle all errors explicitly; no silent failures
|
||||||
|
- Log internal errors; keep user-facing messages helpful
|
||||||
|
- Add context with `.context()` or `.map_err()`
|
||||||
|
|
||||||
|
### 4.2 Log Format
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// 统一使用 AppError 和 AppResult
|
|
||||||
// Use AppError and AppResult consistently
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
|
|
||||||
pub async fn create_user(req: CreateUserReq) -> AppResult<User> {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **AppError**: 统一错误枚举,包含领域错误和外部库包装
|
|
||||||
- **AppResult<T>**: `Result<T, AppError>` 的类型别名
|
|
||||||
|
|
||||||
### 4.2 错误处理原则 / Error Handling Principles
|
|
||||||
|
|
||||||
| 原则 / Principle | 说明 / Description |
|
|
||||||
|----------------|---------------------------------------------------------------------------------|
|
|
||||||
| 显式处理 | Handle all errors explicitly; no silent failures |
|
|
||||||
| 用户友好 | Internal errors are logged and masked; user-facing messages should be helpful |
|
|
||||||
| 错误上下文 | Use `.context()` or `.map_err()` to add meaningful context to errors |
|
|
||||||
| 错误分类 | Domain errors (UserNotFound) vs Infrastructure errors (DatabaseError) |
|
|
||||||
| Postgres 映射 | Map Postgres error codes (23505 unique, 23503 FK, 23514 check) to HTTP statuses |
|
|
||||||
|
|
||||||
### 4.3 错误日志格式 / Error Logging Format
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// 记录错误时包含完整上下文
|
|
||||||
// Log errors with full context
|
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
error = %err,
|
error = %err,
|
||||||
user_id = %user_id,
|
operation = "operation_name",
|
||||||
operation = "create_user",
|
"Failed to perform operation"
|
||||||
"Failed to create user"
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 错误恢复策略 / Error Recovery
|
|
||||||
|
|
||||||
| 场景 / Scenario | 策略 / Strategy |
|
|
||||||
|---------------|---------------|
|
|
||||||
| 数据库连接失败 | 重试 + 降级到只读模式 |
|
|
||||||
| 外部服务超时 | 断路器 + 降级响应 |
|
|
||||||
| 缓存 miss | 回退到数据库查询 |
|
|
||||||
| 队列积压 | 背压控制 + 告警 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 安全规范 / Security
|
|
||||||
|
|
||||||
### 5.1 基础安全 / Basic Security
|
|
||||||
|
|
||||||
| 规则 / Rule | 说明 / Description |
|
|
||||||
|-----------|---------------------------------------------------------------|
|
|
||||||
| 密钥管理 | Never hardcode secrets or API keys; use environment variables |
|
|
||||||
| 输入验证 | Always validate and sanitize user input |
|
|
||||||
| SQL 注入 | Use parameterized queries (sqlx handles this automatically) |
|
|
||||||
| XSS 防护 | Escape output; use Content-Security-Policy headers |
|
|
||||||
| CSRF 防护 | Use CSRF tokens for state-changing operations |
|
|
||||||
| 密码安全 | Argon2 hashing with session-scoped RSA-2048 OAEP-SHA256 |
|
|
||||||
| 2FA | TOTP with HMAC-SHA1, base32 secrets, backup codes |
|
|
||||||
|
|
||||||
### 5.2 OWASP Top 10 防护 / OWASP Top 10 Protection
|
|
||||||
|
|
||||||
| 风险 / Risk | 防护措施 / Mitigation |
|
|
||||||
|-----------|------------------------------------------------------|
|
|
||||||
| 注入 | Parameterized queries, input validation |
|
|
||||||
| 失效认证 | Strong password policy, 2FA, session management |
|
|
||||||
| 敏感数据暴露 | Encryption at rest and in transit, data masking |
|
|
||||||
| XML 外部实体 | Disable XML external entity processing |
|
|
||||||
| 失效访问控制 | Role-based access control, resource ownership checks |
|
|
||||||
| 安全配置错误 | Secure defaults, environment-based config |
|
|
||||||
| XSS | Output encoding, CSP headers |
|
|
||||||
| 不安全反序列化 | Validate serialized data, use safe formats |
|
|
||||||
| 使用含漏洞组件 | Regular dependency updates, `cargo audit` |
|
|
||||||
| 日志和监控不足 | Comprehensive logging, alerting |
|
|
||||||
|
|
||||||
### 5.3 企业级安全 / Enterprise Security
|
|
||||||
|
|
||||||
| 要求 / Requirement | 说明 / Description |
|
|
||||||
|------------------|----------------------------------------------------------------------|
|
|
||||||
| 安全审计日志 | Log all sensitive operations with actor, action, resource, timestamp |
|
|
||||||
| 访问控制 | Implement RBAC/ABAC; check permissions at service layer |
|
|
||||||
| 数据脱敏 | Mask PII in logs; encrypt sensitive fields in database |
|
|
||||||
| 依赖安全 | Run `cargo audit` in CI; review new dependencies |
|
|
||||||
| 安全头 | Set HSTS, X-Frame-Options, X-Content-Type-Options, etc. |
|
|
||||||
| 速率限制 | Implement rate limiting for auth endpoints and API calls |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 数据库规范 / Database
|
|
||||||
|
|
||||||
### 6.1 基础规范 / Basic Rules
|
|
||||||
|
|
||||||
| 规则 / Rule | 说明 / Description |
|
|
||||||
|-----------|----------------------------------------------------------------------|
|
|
||||||
| 参数化查询 | Always use parameterized queries (sqlx does this by default) |
|
|
||||||
| 事务管理 | Use `ServiceContext::run_in_transaction()` for multi-step operations |
|
|
||||||
| 读写分离 | Use `AppDatabase` read/write pool methods appropriately |
|
|
||||||
| 迁移规范 | All schema changes must go through migration files in `migrate/` |
|
|
||||||
|
|
||||||
### 6.2 性能优化 / Performance Optimization
|
|
||||||
|
|
||||||
| 规则 / Rule | 说明 / Description |
|
|
||||||
|-----------|----------------------------------------------------------------------|
|
|
||||||
| N+1 防护 | Use `JOIN` or batch queries instead of N+1 patterns |
|
|
||||||
| 批量操作 | Use `INSERT ... ON CONFLICT`, `UPDATE ... FROM`, bulk operations |
|
|
||||||
| 索引规范 | Add indexes for frequently queried columns; document index rationale |
|
|
||||||
| 查询分析 | Use `EXPLAIN ANALYZE` to verify query plans for complex queries |
|
|
||||||
| 连接池 | Configure pool sizes based on workload; monitor connection usage |
|
|
||||||
| 慢查询 | Log queries >100ms; investigate and optimize |
|
|
||||||
|
|
||||||
### 6.3 数据一致性 / Data Consistency
|
|
||||||
|
|
||||||
| 规则 / Rule | 说明 / Description |
|
|
||||||
|-----------|-----------------------------------------------------------|
|
|
||||||
| 事务边界 | Keep transactions short; avoid long-running transactions |
|
|
||||||
| 幂等性 | Design operations to be idempotent where possible |
|
|
||||||
| 乐观锁 | Use version columns for concurrent update protection |
|
|
||||||
| 外键约束 | Use database-level foreign keys for referential integrity |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. API 设计规范 / API Design
|
|
||||||
|
|
||||||
### 7.1 RESTful 规范 / RESTful Conventions
|
|
||||||
|
|
||||||
| 规则 / Rule | 示例 / Example |
|
|
||||||
|-----------|-----------------------------------------------------------------------------------------|
|
|
||||||
| 资源命名 | `/api/v1/workspaces/{id}/repos` (复数名词) |
|
|
||||||
| HTTP 方法 | GET (读取), POST (创建), PUT/PATCH (更新), DELETE (删除) |
|
|
||||||
| 状态码 | 200 (成功), 201 (创建), 204 (无内容), 400 (客户端错误), 401 (未认证), 403 (禁止), 404 (未找到), 500 (服务器错误) |
|
|
||||||
| 版本管理 | URL path versioning: `/api/v1/...` |
|
|
||||||
|
|
||||||
### 7.2 响应格式 / Response Format
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// 统一响应类型
|
|
||||||
// Unified response types
|
|
||||||
ApiResponse<T> // 单个数据 / Single payload
|
|
||||||
ApiListResponse<T> // 分页列表 / Paginated list { data, total, page, per_page }
|
|
||||||
ApiEmptyResponse // 空响应 / Empty response
|
|
||||||
ApiErrorResponse // 错误响应 / Error response { code, message, details }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 OpenAPI 文档 / OpenAPI Documentation
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// 每个端点必须添加 OpenAPI 注解
|
|
||||||
// Every endpoint must have OpenAPI annotations
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/login",
|
|
||||||
request_body = LoginReq,
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Login successful", body = ApiResponse<LoginResp>),
|
|
||||||
(status = 401, description = "Invalid credentials", body = ApiErrorResponse)
|
|
||||||
),
|
|
||||||
tag = "auth"
|
|
||||||
)]
|
|
||||||
pub async fn login(...) -> HttpResponse { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.4 API 治理 / API Governance
|
|
||||||
|
|
||||||
| 规则 / Rule | 说明 / Description |
|
|
||||||
|---|---|
|
|
||||||
| 请求验证 | Validate all request bodies and query parameters |
|
|
||||||
| 速率限制 | Apply rate limiting to auth and resource-intensive endpoints |
|
|
||||||
| 幂等性 | POST operations with same idempotency key should produce same result |
|
|
||||||
| 缓存策略 | Use ETag/Last-Modified for cacheable resources |
|
|
||||||
| 错误码体系 | Consistent error codes across all endpoints |
|
|
||||||
| 分页 | Default page size 20, max 100; use cursor-based pagination for large datasets |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 日志与可观测性 / Logging & Observability
|
|
||||||
|
|
||||||
### 8.1 日志规范 / Logging Standards
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// 使用 tracing crate 进行结构化日志
|
|
||||||
// Use tracing crate for structured logging
|
|
||||||
use tracing::{info, warn, error, debug, instrument};
|
|
||||||
|
|
||||||
#[instrument(skip(db), fields(user_id = %req.user_id))]
|
|
||||||
pub async fn create_user(req: CreateUserReq) -> AppResult<User> {
|
|
||||||
info!("Creating new user");
|
|
||||||
// ...
|
|
||||||
error!(error = %err, "Failed to create user");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| 级别 / Level | 用途 / Usage |
|
|
||||||
|---|---|
|
|
||||||
| `error` | 错误需要立即关注 / Errors requiring immediate attention |
|
|
||||||
| `warn` | 异常但可恢复的情况 / Abnormal but recoverable situations |
|
|
||||||
| `info` | 关键业务操作记录 / Key business operation records |
|
|
||||||
| `debug` | 开发调试信息 / Development debugging info |
|
|
||||||
| `trace` | 详细执行路径 / Detailed execution paths |
|
|
||||||
|
|
||||||
### 8.2 敏感信息脱敏 / Data Masking
|
|
||||||
|
|
||||||
| 数据类型 / Data Type | 脱敏规则 / Masking Rule |
|
|
||||||
|---|---|
|
|
||||||
| 密码 | 完全隐藏 / Never log |
|
|
||||||
| Token/密钥 | 只显示前 4 位 / Show first 4 chars only |
|
|
||||||
| 邮箱 | `u***@example.com` |
|
|
||||||
| IP 地址 | 保留网段 / Keep subnet |
|
|
||||||
| 个人信息 | 根据最小必要原则 / Minimum necessary principle |
|
|
||||||
|
|
||||||
### 8.3 性能指标 / Metrics
|
|
||||||
|
|
||||||
| 指标 / Metric | 说明 / Description |
|
|
||||||
|---|---|
|
|
||||||
| 请求延迟 | HTTP request latency (P50, P95, P99) |
|
|
||||||
| 错误率 | Error rate by endpoint and status code |
|
|
||||||
| 吞吐量 | Requests per second |
|
|
||||||
| 数据库连接 | Active/idle connections in pool |
|
|
||||||
| 缓存命中率 | Cache hit/miss ratio |
|
|
||||||
| 队列积压 | Queue depth and processing rate |
|
|
||||||
| 内存使用 | Heap usage, allocation rate |
|
|
||||||
| 活跃会话 | Active WebSocket sessions |
|
|
||||||
|
|
||||||
### 8.4 健康检查 / Health Checks
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// 端点: GET /health
|
|
||||||
// Endpoint: GET /health
|
|
||||||
{
|
|
||||||
"status": "healthy", // healthy | degraded | unhealthy
|
|
||||||
"version": "1.0.0",
|
|
||||||
"uptime": 3600,
|
|
||||||
"checks": {
|
|
||||||
"database": { "status": "up", "latency_ms": 5 },
|
|
||||||
"redis": { "status": "up", "latency_ms": 2 },
|
|
||||||
"nats": { "status": "up", "latency_ms": 1 },
|
|
||||||
"etcd": { "status": "up", "latency_ms": 3 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.5 告警规则 / Alerting Rules
|
|
||||||
|
|
||||||
| 条件 / Condition | 级别 / Level |
|
|
||||||
|---|---|
|
|
||||||
| 错误率 > 5% | Critical |
|
|
||||||
| P99 延迟 > 500ms | Warning |
|
|
||||||
| 数据库连接池 > 80% | Warning |
|
|
||||||
| 队列积压 > 1000 | Critical |
|
|
||||||
| 内存使用 > 85% | Warning |
|
|
||||||
| 健康检查失败 | Critical |
|
|
||||||
|
|
||||||
### 8.6 请求链路追踪 / Request Tracing
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// 每个请求分配唯一 trace_id
|
|
||||||
// Each request gets a unique trace_id
|
|
||||||
tracing::info!(
|
|
||||||
trace_id = %request_id,
|
|
||||||
user_id = %session.user_id,
|
|
||||||
method = %req.method(),
|
|
||||||
path = %req.path(),
|
|
||||||
"Request started"
|
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 性能规范 / Performance
|
## 5. Security
|
||||||
|
|
||||||
### 9.1 SLA 目标 / SLA Targets
|
- Never hardcode secrets or API keys
|
||||||
|
- Always validate and sanitize user input
|
||||||
| 指标 / Metric | 目标 / Target |
|
- Use parameterized queries (no SQL injection)
|
||||||
|---|---|
|
- Use proper password hashing (Argon2, bcrypt)
|
||||||
| 可用性 | 99.9% (每月宕机 <43 分钟) |
|
|
||||||
| P50 延迟 | <50ms |
|
|
||||||
| P95 延迟 | <200ms |
|
|
||||||
| P99 延迟 | <500ms |
|
|
||||||
| 错误率 | <0.1% |
|
|
||||||
| 数据库查询 | <100ms (常规查询) |
|
|
||||||
| 缓存命中率 | >90% |
|
|
||||||
|
|
||||||
### 9.2 性能原则 / Performance Principles
|
|
||||||
|
|
||||||
| 原则 / Principle | 说明 / Description |
|
|
||||||
|---|---|
|
|
||||||
| 基准测试 | Establish performance baselines before optimization |
|
|
||||||
| 测量优先 | Profile before optimizing; don't guess |
|
|
||||||
| 渐进优化 | Optimize iteratively; measure impact of each change |
|
|
||||||
| 容量规划 | Plan for 3x current load |
|
|
||||||
|
|
||||||
### 9.3 优化策略 / Optimization Strategies
|
|
||||||
|
|
||||||
| 场景 / Scenario | 策略 / Strategy |
|
|
||||||
|---|---|
|
|
||||||
| 热点查询 | Add caching (L1 + L2) |
|
|
||||||
| 大量读取 | Use read replicas |
|
|
||||||
| 批量操作 | Batch database operations |
|
|
||||||
| 高并发 | Use connection pooling, async I/O |
|
|
||||||
| 大数据量 | Use cursor-based pagination |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 测试规范 / Testing
|
## 6. Workflow
|
||||||
|
|
||||||
### 10.1 基础要求 / Basic Requirements
|
### 6.1 Development Flow
|
||||||
|
|
||||||
| 规则 / Rule | 说明 / Description |
|
1. **Read before write** — understand context first
|
||||||
|---|---|
|
2. **Minimal changes** — don't refactor unrelated code
|
||||||
| 新功能 | All new features must have unit tests |
|
3. **Verify after changes** — run tests or check output
|
||||||
| Bug 修复 | Bug fixes must include regression tests |
|
|
||||||
| 关键路径 | Critical business logic must have integration tests |
|
|
||||||
| 测试隔离 | Tests must be independent and not depend on execution order |
|
|
||||||
|
|
||||||
### 10.2 测试命令 / Test Commands
|
### 6.2 AI Assistant Rules
|
||||||
|
|
||||||
|
- Always read existing code before making changes
|
||||||
|
- Make minimal changes
|
||||||
|
- Run `cargo check` or `cargo test` after changes
|
||||||
|
- Explain what you changed and why
|
||||||
|
|
||||||
|
### 6.3 Common Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test # 运行所有测试 / Run all tests
|
cargo build # Build
|
||||||
cargo test -- <test_name> # 按名称运行 / Run by name
|
cargo check # Quick syntax check
|
||||||
cargo test lru::tests # 运行特定模块 / Run module tests
|
cargo test # Run tests
|
||||||
cargo test -- --nocapture # 显示输出 / Show output
|
cargo clippy # Lint
|
||||||
```
|
cargo fmt # Format
|
||||||
|
|
||||||
### 10.3 测试命名 / Test Naming
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_create_user_with_valid_input() { ... }
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_create_user_with_duplicate_email_returns_error() { ... }
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_async_operation_handles_timeout() { ... }
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Git 规范 / Git Workflow
|
## 7. Git Workflow
|
||||||
|
|
||||||
### 11.1 提交信息格式 / Commit Message Format
|
### 7.1 Commit Message Format
|
||||||
|
|
||||||
使用 Angular 风格,全部英文:
|
|
||||||
|
|
||||||
Use Angular style, all English:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
<type>(<scope>): <subject>
|
<type>(<scope>): <subject>
|
||||||
@@ -473,182 +125,37 @@ Use Angular style, all English:
|
|||||||
[optional footer]
|
[optional footer]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Type | 说明 / Description |
|
Types: `feat` · `fix` · `refactor` · `docs` · `test` · `chore`
|
||||||
|---|---|
|
|
||||||
| `feat` | 新功能 / New feature |
|
|
||||||
| `fix` | Bug 修复 / Bug fix |
|
|
||||||
| `refactor` | 重构 / Code refactoring |
|
|
||||||
| `docs` | 文档 / Documentation |
|
|
||||||
| `test` | 测试 / Tests |
|
|
||||||
| `chore` | 构建/工具 / Build/tooling |
|
|
||||||
| `perf` | 性能优化 / Performance improvement |
|
|
||||||
| `style` | 代码格式 / Code formatting |
|
|
||||||
| `ci` | CI/CD 相关 / CI/CD changes |
|
|
||||||
|
|
||||||
**示例 / Examples:**
|
### 7.2 Commit Principles
|
||||||
```
|
|
||||||
feat(auth): add 2FA login support
|
|
||||||
fix(api): resolve race condition in user creation
|
|
||||||
refactor(service): extract common validation logic
|
|
||||||
docs(readme): update API documentation
|
|
||||||
test(cache): add unit tests for LRU eviction
|
|
||||||
chore(deps): update sqlx to 0.8
|
|
||||||
```
|
|
||||||
|
|
||||||
### 11.2 提交原则 / Commit Principles
|
- Each commit addresses one concern (atomic)
|
||||||
|
- Each commit leaves the codebase in a working state
|
||||||
| 原则 / Principle | 说明 / Description |
|
- Never force push to `main`
|
||||||
|---|---|
|
|
||||||
| 原子提交 | Each commit should address one concern |
|
|
||||||
| 完整性 | Each commit should leave the codebase in a working state |
|
|
||||||
| 禁止强制推送 | Never force push to main branch |
|
|
||||||
| 提交前检查 | Run `cargo check` and `cargo test` before committing |
|
|
||||||
|
|
||||||
### 11.3 分支策略 / Branch Strategy
|
|
||||||
|
|
||||||
| 分支 / Branch | 用途 / Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `main` | 生产就绪代码 / Production-ready code |
|
|
||||||
| `feat/*` | 功能开发 / Feature development |
|
|
||||||
| `fix/*` | Bug 修复 / Bug fixes |
|
|
||||||
| `release/*` | 发布准备 / Release preparation |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. 工作流程 / Workflow
|
## Appendix: Architecture Overview
|
||||||
|
|
||||||
### 12.1 开发流程 / Development Process
|
```
|
||||||
|
gitks — Git Repository Operations Service
|
||||||
|
|
||||||
1. **理解先于编写** — Read before write; understand context first
|
actor/ → Actor model
|
||||||
2. **最小变更** — Minimal changes; don't refactor unrelated code
|
archive/ → Archive operations
|
||||||
3. **验证变更** — Verify after changes; run tests or check output
|
blame/ → Blame operations
|
||||||
4. **文档同步** — Update documentation when changing public APIs
|
blob/ → Blob objects
|
||||||
|
branch/ → Branch operations
|
||||||
### 12.2 AI 助手工作规范 / AI Assistant Guidelines
|
commit/ → Commit operations
|
||||||
|
diff/ → Diff operations
|
||||||
| 规则 / Rule | 说明 / Description |
|
merge/ → Merge operations
|
||||||
|---|---|
|
pack/ → Pack operations
|
||||||
| 先读后写 | Always read existing code before making changes |
|
refs/ → Reference management
|
||||||
| 最小侵入 | Make minimal changes; don't refactor unrelated code |
|
remote/ → Remote operations
|
||||||
| 验证结果 | Run `cargo check` or `cargo test` after changes |
|
repository/ → Repository operations
|
||||||
| 解释变更 | Explain what you changed and why |
|
server/ → gRPC server
|
||||||
| 询问不确定 | Ask when unsure about requirements |
|
|
||||||
|
|
||||||
### 12.3 常用命令 / Common Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build # 构建 / Build
|
|
||||||
cargo check # 快速检查 / Quick check
|
|
||||||
cargo test # 运行测试 / Run tests
|
|
||||||
cargo clippy # Lint 检查 / Lint checks
|
|
||||||
cargo fmt # 格式化 / Format code
|
|
||||||
cargo doc --no-deps # 生成文档 / Build docs
|
|
||||||
cargo machete # 检查未使用依赖 / Check unused deps
|
|
||||||
cargo run --bin gen_openapi # 生成 OpenAPI / Generate OpenAPI
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. 架构决策记录 / ADR
|
*For questions or suggestions, please open an issue.*
|
||||||
|
|
||||||
架构决策记录存放在 `docs/adr/` 目录下,使用 Markdown 格式。
|
|
||||||
|
|
||||||
Architecture Decision Records are stored in `docs/adr/` directory in Markdown format.
|
|
||||||
|
|
||||||
### 索引 / Index
|
|
||||||
|
|
||||||
| ADR | 标题 / Title | 状态 / Status |
|
|
||||||
|---|---|---|
|
|
||||||
| [ADR-001](docs/adr/001-choice-of-web-framework.md) | 选择 Actix-web 作为 Web 框架 | Accepted |
|
|
||||||
| [ADR-002](docs/adr/002-two-tier-caching.md) | 两级缓存架构 (L1 LRU + L2 Redis) | Accepted |
|
|
||||||
| [ADR-003](docs/adr/003-nats-for-messaging.md) | 使用 NATS JetStream 作为消息队列 | Accepted |
|
|
||||||
| [ADR-004](docs/adr/004-etcd-for-discovery.md) | 使用 etcd 进行服务发现 | Accepted |
|
|
||||||
| [ADR-005](docs/adr/005-error-handling-strategy.md) | 统一错误处理策略 | Accepted |
|
|
||||||
|
|
||||||
### ADR 模板 / ADR Template
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# ADR-NNN: 标题 / Title
|
|
||||||
|
|
||||||
## 状态 / Status
|
|
||||||
Accepted | Superseded | Deprecated
|
|
||||||
|
|
||||||
## 背景 / Context
|
|
||||||
描述问题背景 / Describe the context
|
|
||||||
|
|
||||||
## 决策 / Decision
|
|
||||||
描述做出的决策 / Describe the decision
|
|
||||||
|
|
||||||
## 后果 / Consequences
|
|
||||||
描述正面和负面影响 / Describe positive and negative impacts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. 审查清单 / Review Checklist
|
|
||||||
|
|
||||||
### 代码审查 / Code Review
|
|
||||||
|
|
||||||
- [ ] 代码风格符合项目规范 / Code style follows project conventions
|
|
||||||
- [ ] 没有使用禁止模式 / No forbidden patterns used
|
|
||||||
- [ ] 错误处理完整 / Error handling is complete
|
|
||||||
- [ ] 安全考虑已处理 / Security considerations addressed
|
|
||||||
- [ ] 性能影响已评估 / Performance impact assessed
|
|
||||||
- [ ] 测试已添加 / Tests are added
|
|
||||||
- [ ] 文档已更新 / Documentation is updated
|
|
||||||
|
|
||||||
### PR 审查 / PR Review
|
|
||||||
|
|
||||||
- [ ] 提交信息符合 Angular 风格 / Commit messages follow Angular style
|
|
||||||
- [ ] 每个提交只关注一个问题 / Each commit addresses one concern
|
|
||||||
- [ ] 变更范围合理 / Change scope is reasonable
|
|
||||||
- [ ] 没有遗留的 TODO/FIXME / No leftover TODO/FIXME
|
|
||||||
- [ ] CI 检查通过 / CI checks pass
|
|
||||||
|
|
||||||
### 发布前审查 / Pre-release Review
|
|
||||||
|
|
||||||
- [ ] 所有测试通过 / All tests pass
|
|
||||||
- [ ] 性能测试完成 / Performance tests completed
|
|
||||||
- [ ] 安全扫描通过 / Security scan passed
|
|
||||||
- [ ] 文档完整 / Documentation is complete
|
|
||||||
- [ ] 变更日志已更新 / Changelog is updated
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 附录 / Appendix
|
|
||||||
|
|
||||||
### 项目架构速查 / Quick Architecture Reference
|
|
||||||
|
|
||||||
```
|
|
||||||
appks — 协作开发平台后端 / Collaborative Development Platform Backend
|
|
||||||
|
|
||||||
config/ → 环境配置 / Environment configuration
|
|
||||||
models/ → 数据模型 / Data models (sqlx FromRow)
|
|
||||||
service/ → 业务逻辑 / Business logic (AppService)
|
|
||||||
api/ → HTTP 端点 / HTTP endpoints
|
|
||||||
immediate/ → 实时 IM / Real-time IM (WebSocket)
|
|
||||||
cache/ → 两级缓存 / Two-tier cache (L1 + L2)
|
|
||||||
storage/ → 对象存储 / Object storage (S3)
|
|
||||||
queue/ → 消息队列 / Message queue (NATS)
|
|
||||||
etcd/ → 服务发现 / Service discovery
|
|
||||||
session/ → 会话管理 / Session management
|
|
||||||
pb/ → gRPC 客户端 / gRPC client stubs
|
|
||||||
proto/ → Protobuf 定义 / Protobuf definitions
|
|
||||||
migrate/ → 数据库迁移 / Database migrations
|
|
||||||
error.rs → 统一错误类型 / Unified error types
|
|
||||||
```
|
|
||||||
|
|
||||||
### 基础设施速查 / Infrastructure Quick Reference
|
|
||||||
|
|
||||||
| 服务 / Service | 用途 / Purpose | 协议 / Protocol |
|
|
||||||
|--------------|-----------------------------------------|---------------|
|
|
||||||
| Postgres | 主数据库 / Primary database | sqlx |
|
|
||||||
| Redis | 缓存/会话/限流 / Cache/sessions/rate limiting | redis + r2d2 |
|
|
||||||
| etcd | 服务发现 / Service discovery | etcd-client |
|
|
||||||
| NATS | 消息队列 / Message queue | async-nats |
|
|
||||||
| S3/MinIO | 对象存储 / Object storage | object_store |
|
|
||||||
| Qdrant | 向量数据库 / Vector DB | config only |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*This document is maintained by the development team. For questions or suggestions, please open an issue.*
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Gitks Security Best Practices
|
||||||
|
|
||||||
|
This document outlines security best practices for the gitks project.
|
||||||
|
|
||||||
|
## Input Validation
|
||||||
|
|
||||||
|
### Revision Strings
|
||||||
|
All revision strings (branch names, commit hashes, refs) are validated using `sanitize::validate_revision()`:
|
||||||
|
- Prevents command injection via `~N` and `^N` operators
|
||||||
|
- Limits revision string length to 256 characters
|
||||||
|
- Limits ancestry depth to 10000 to prevent DoS attacks
|
||||||
|
- Validates branch name characters to prevent shell metacharacter injection
|
||||||
|
|
||||||
|
### File Paths
|
||||||
|
File paths are validated using `sanitize::validate_file_path()`:
|
||||||
|
- Rejects absolute paths
|
||||||
|
- Blocks path traversal attacks (`..`)
|
||||||
|
- Prevents null byte injection
|
||||||
|
- Blocks `.git` directory access
|
||||||
|
- On Windows, blocks reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
|
||||||
|
|
||||||
|
### Git Configuration Keys
|
||||||
|
Configuration keys are validated using `sanitize::validate_config_key()`:
|
||||||
|
- Blocks dangerous keys that could execute arbitrary commands (core.sshCommand, core.hooksPath)
|
||||||
|
- Blocks network-related keys (http.proxy, https.proxy, remote.*.url)
|
||||||
|
- Blocks credential helpers
|
||||||
|
- Only allows alphanumeric characters, dots, hyphens, and underscores
|
||||||
|
|
||||||
|
### Relative Paths
|
||||||
|
Relative paths are validated using `sanitize::validate_relative_path()`:
|
||||||
|
- Rejects absolute paths
|
||||||
|
- Blocks path traversal attacks (`..`)
|
||||||
|
|
||||||
|
## Path Security
|
||||||
|
|
||||||
|
### TOCTOU Prevention
|
||||||
|
Path validation uses a unified approach to prevent Time-Of-Check-Time-Of-Use vulnerabilities:
|
||||||
|
1. Canonicalize the path if it exists
|
||||||
|
2. If path doesn't exist, validate parent directory and filename separately
|
||||||
|
3. Verify canonical path starts with allowed prefix
|
||||||
|
4. Reject any path that escapes the allowed directory
|
||||||
|
|
||||||
|
### Cache Invalidation
|
||||||
|
Cache entries are invalidated when repositories are modified:
|
||||||
|
- Uses precise substring matching on relative path
|
||||||
|
- Invalidates all cache keys containing the modified repository path
|
||||||
|
- Prevents stale data from being served after modifications
|
||||||
|
|
||||||
|
## Message Decoding Security
|
||||||
|
|
||||||
|
### String Decoding
|
||||||
|
The `decode_strings()` function in `actor/message.rs` includes:
|
||||||
|
- Total message size limit (50MB)
|
||||||
|
- Individual string length limit (10MB)
|
||||||
|
- Overflow protection using `checked_add()`
|
||||||
|
- Graceful degradation on malformed data
|
||||||
|
|
||||||
|
## Cluster Registration
|
||||||
|
|
||||||
|
### Primary/Replica Role Assignment
|
||||||
|
When registering repositories in a cluster:
|
||||||
|
- Single node: registers as PRIMARY
|
||||||
|
- Multiple nodes: registers as REPLICA initially
|
||||||
|
- Final role determination happens at query time via `route_repository`
|
||||||
|
- This conservative approach prevents split-brain scenarios
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
All security-critical functions have comprehensive unit tests:
|
||||||
|
- `tests/sanitize_test.rs`: Input validation tests
|
||||||
|
- `tests/macro_test.rs`: Revision resolution tests
|
||||||
|
- Tests cover both valid and malicious inputs
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- All code passes `cargo clippy --all-targets --all-features` with zero warnings
|
||||||
|
- Code is formatted with `cargo fmt`
|
||||||
|
- All tests pass with `cargo test`
|
||||||
|
- No known security vulnerabilities in dependencies (verified with `cargo deny`)
|
||||||
|
|
||||||
|
## Recommendations for Users
|
||||||
|
|
||||||
|
1. **Never trust user input**: Always validate revisions, paths, and config keys
|
||||||
|
2. **Use the sanitize module**: All user-provided strings should go through validation
|
||||||
|
3. **Keep dependencies updated**: Run `cargo update` regularly and check for security advisories
|
||||||
|
4. **Monitor logs**: Watch for validation failures which may indicate attack attempts
|
||||||
|
5. **Limit cluster size**: The cluster registration logic assumes a reasonable number of nodes
|
||||||
|
6. **Use HTTPS**: When deploying in production, use TLS for gRPC connections
|
||||||
|
7. **Audit configuration**: Regularly review which git config keys are allowed
|
||||||
|
|
||||||
|
## Reporting Security Issues
|
||||||
|
|
||||||
|
If you discover a security vulnerability, please report it responsibly by:
|
||||||
|
1. Creating a private security advisory
|
||||||
|
2. Providing detailed reproduction steps
|
||||||
|
3. Allowing maintainers time to address the issue before public disclosure
|
||||||
Generated
+1194
-3621
File diff suppressed because it is too large
Load Diff
+39
-47
@@ -1,61 +1,53 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "appks"
|
name = "gitks"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
authors = ["gitks contributors"]
|
||||||
|
description = "A gRPC-accessible Git repository operations library for bare repositories"
|
||||||
|
repository = "https://github.com/appks/gitks"
|
||||||
|
homepage = "https://github.com/appks/gitks"
|
||||||
|
license = "PolyForm-Noncommercial-1.0.0"
|
||||||
|
keywords = ["git", "grpc", "bare-repository", "gix"]
|
||||||
|
categories = ["development-tools"]
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "appks"
|
|
||||||
path = "lib.rs"
|
path = "lib.rs"
|
||||||
|
name = "gitks"
|
||||||
[[bin]]
|
|
||||||
name = "appks"
|
|
||||||
path = "main.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "gen_openapi"
|
|
||||||
path = "gen_openapi.rs"
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
sqlx = { version = "0.9", features = ["postgres","runtime-tokio","chrono","uuid","json","migrate"] }
|
moka = { version = "0.12", default-features = false, features = ["sync"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = { version = "1", features = [] }
|
serde_json = "1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
sha2 = "0.11"
|
||||||
uuid = { version = "1", features = ["serde","v4","v7","v5"] }
|
uuid = { version = "1", features = ["v7"] }
|
||||||
reqwest = { version = "0.13", features = ["json"] }
|
gix = { version = "0.84", default-features = false, features = ["serde", "blame", "sha256", "sha1", "tracing", "merge", "max-performance-safe", "revision"] }
|
||||||
tracing = { version = "0.1", features = [] }
|
gix-archive = { version = "0.33", features = ["sha256","sha1","document-features"] }
|
||||||
tracing-subscriber = { version = "0.3", features = ["fmt"] }
|
duct = { version = "1", features = [] }
|
||||||
dotenvy = "0.15"
|
tracing = { version = "0.1", features = ["log"] }
|
||||||
thiserror = "2"
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
redis = { version = "1", features = ["cluster","cluster-async","aio","tokio-comp","connection-manager"] }
|
tracing-appender = "0.2"
|
||||||
dashmap = "6"
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "sync", "net", "signal"] }
|
||||||
object_store = { version = "0.13", features = ["tokio","aws","cloud"] }
|
tokio-stream = { version = "0.1", features = ["full"] }
|
||||||
argon2 = "0.5"
|
tokio-util = "0.7"
|
||||||
rsa = "0.9"
|
thiserror = { version = "2", features = [] }
|
||||||
chacha20poly1305 = "0.10"
|
|
||||||
hkdf = "0.12"
|
|
||||||
sha2 = "0.10"
|
|
||||||
sha1 = "0.10"
|
|
||||||
hmac = "0.12"
|
|
||||||
jsonwebtoken = "9"
|
|
||||||
arc-swap = "1"
|
|
||||||
base64 = "0.22"
|
|
||||||
rand = "0.8"
|
|
||||||
captcha-rs = "0.5"
|
|
||||||
tonic = { version = "0.14", features = ["transport", "channel"] }
|
|
||||||
prost = "0.14"
|
prost = "0.14"
|
||||||
prost-types = "0.14"
|
prost-types = "0.14"
|
||||||
|
tonic = { version = "0.14", features = ["transport", "gzip"] }
|
||||||
|
tonic-health = "0.14"
|
||||||
tonic-prost = "0.14"
|
tonic-prost = "0.14"
|
||||||
tonic-health = "0.14.6"
|
tempfile = "3"
|
||||||
url = "2.5"
|
dotenvy = "0.15"
|
||||||
etcd-client = { version = "0.18", features = ["tls"] }
|
etcd-client = { version = "0.18", features = ["tls"] }
|
||||||
tokio-stream = { version = "0.1", features = ["net"] }
|
dashmap = "6"
|
||||||
async-nats = "0.49"
|
hyper = { version = "1", features = ["server", "http1"] }
|
||||||
futures-util = "0.3"
|
hyper-util = { version = "0.1", features = ["tokio"] }
|
||||||
utoipa = { version = "5", features = ["uuid","chrono","actix_extras","decimal","macros"]}
|
http-body-util = "0.1"
|
||||||
actix-web = { version = "4", features = ["secure-cookies"] }
|
bytes = "1"
|
||||||
actix-multipart = "0.7"
|
crc32fast = "1"
|
||||||
hex = "0.4"
|
[[bin]]
|
||||||
|
name = "gitks"
|
||||||
|
path = "main.rs"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tonic-prost-build = "0.14"
|
tonic-prost-build = "0.14"
|
||||||
|
serde_yml = "0.0.12"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
+10
-10
@@ -14,19 +14,19 @@ FROM chef AS builder
|
|||||||
COPY --from=planner /app/recipe.json recipe.json
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
RUN cargo chef cook --release --recipe-path recipe.json
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN cargo build --release --bin appks && \
|
RUN cargo build --release --bin gitks && \
|
||||||
strip target/release/appks
|
strip target/release/gitks
|
||||||
|
|
||||||
FROM ubuntu:26.04
|
FROM ubuntu:26.04
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends ca-certificates && \
|
apt-get install -y --no-install-recommends git ca-certificates && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=builder /app/target/release/appks /usr/local/bin/appks
|
COPY --from=builder /app/target/release/gitks /usr/local/bin/gitks
|
||||||
|
|
||||||
ENV APP_HTTP_HOST=0.0.0.0
|
ENV GITKS_HOST=0.0.0.0
|
||||||
ENV APP_HTTP_PORT=8000
|
ENV GITKS_PORT=50051
|
||||||
ENV APP_RPC_SELF_HOST=0.0.0.0
|
ENV REPO_PREFIX_PATH=/data/repos
|
||||||
ENV APP_RPC_SELF_PORT=50049
|
|
||||||
|
|
||||||
EXPOSE 8000 50049
|
RUN mkdir -p /data/repos
|
||||||
ENTRYPOINT ["appks"]
|
EXPOSE 50051
|
||||||
|
ENTRYPOINT ["gitks"]
|
||||||
|
|||||||
+9
-8
@@ -1,16 +1,17 @@
|
|||||||
FROM ubuntu:26.04
|
FROM ubuntu:26.04
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends ca-certificates && \
|
apt-get install -y --no-install-recommends git && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY target/release/appks /usr/local/bin/appks
|
COPY target/release/gitks /usr/local/bin/gitks
|
||||||
|
|
||||||
ENV APP_HTTP_HOST=0.0.0.0
|
ENV GITKS_HOST=0.0.0.0
|
||||||
ENV APP_HTTP_PORT=8000
|
ENV GITKS_PORT=50051
|
||||||
ENV APP_RPC_SELF_HOST=0.0.0.0
|
ENV REPO_PREFIX_PATH=/data/repos
|
||||||
ENV APP_RPC_SELF_PORT=50049
|
|
||||||
|
|
||||||
EXPOSE 8000 50049
|
RUN mkdir -p /data/repos
|
||||||
|
|
||||||
ENTRYPOINT ["appks"]
|
EXPOSE 50051
|
||||||
|
|
||||||
|
ENTRYPOINT ["gitks"]
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
PolyForm Noncommercial License 1.0.0
|
||||||
|
|
||||||
|
Copyright (c) 2024 gitks contributors
|
||||||
|
|
||||||
|
License: "Noncommercial" as defined below.
|
||||||
|
|
||||||
|
"Noncommercial" means primarily intended for or directed towards the
|
||||||
|
advantage or monetary gain of a business, commercial entity, or for-profit
|
||||||
|
organization. A use is "Noncommercial" if it is not primarily intended for
|
||||||
|
or directed towards commercial advantage or monetary compensation.
|
||||||
|
|
||||||
|
1. Grant of Copyright License. Subject to the terms of this license,
|
||||||
|
Licensor grants you a worldwide, royalty-free, non-exclusive, limited
|
||||||
|
license to exercise the Licensed Rights in the Licensed Material for
|
||||||
|
Noncommercial purposes only.
|
||||||
|
|
||||||
|
2. Grant of Patent License. Subject to the terms of this license, Licensor
|
||||||
|
grants you a worldwide, royalty-free, non-exclusive, limited license
|
||||||
|
under patent claims owned or controlled by Licensor that are embodied
|
||||||
|
in the Licensed Material as furnished by Licensor, to make, use, sell,
|
||||||
|
offer for sale, have made, and import the Licensed Material for
|
||||||
|
Noncommercial purposes only.
|
||||||
|
|
||||||
|
3. Limitations. The license granted in Section 1 and Section 2 above is
|
||||||
|
expressly limited to Noncommercial purposes. You may not exercise the
|
||||||
|
Licensed Rights for the purpose of providing services to third parties,
|
||||||
|
including but not limited to:
|
||||||
|
(a) offering the Licensed Material as a hosted or managed service
|
||||||
|
where third parties access or use the Licensed Material;
|
||||||
|
(b) offering the Licensed Material as part of a product or service
|
||||||
|
that is sold, licensed, or otherwise provided for monetary gain;
|
||||||
|
(c) using the Licensed Material to provide consulting, support, or
|
||||||
|
other services for monetary gain.
|
||||||
|
|
||||||
|
4. Acceptance. Any use of the Licensed Material in violation of this
|
||||||
|
license will automatically terminate your rights under this license
|
||||||
|
for the current and all future versions of the Licensed Material.
|
||||||
|
|
||||||
|
5. Patents. If you institute patent litigation against any entity
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
the Licensed Material constitutes direct or contributory patent
|
||||||
|
infringement, then any patent licenses granted to you under this
|
||||||
|
license for the Licensed Material shall terminate as of the date
|
||||||
|
such litigation is filed.
|
||||||
|
|
||||||
|
6. Disclaimer of Warranty. THE LICENSED MATERIAL IS PROVIDED "AS IS" AND
|
||||||
|
WITHOUT ANY WARRANTY OF ANY KIND. LICENSOR DISCLAIMS ALL WARRANTIES,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED WARRANTIES
|
||||||
|
OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE.
|
||||||
|
|
||||||
|
7. Limitation of Liability. IN NO EVENT WILL LICENSOR BE LIABLE TO YOU
|
||||||
|
FOR ANY DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL, OR
|
||||||
|
CONSEQUENTIAL DAMAGES ARISING OUT OF THESE TERMS OR IN CONNECTION
|
||||||
|
WITH THE USE OR INABILITY TO USE THE LICENSED MATERIAL, EVEN IF
|
||||||
|
LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
For the full license text, see: https://polyformproject.org/licenses/noncommercial/1.0.0
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# gitks
|
||||||
|
A Git bare repository operation library based on gRPC.
|
||||||
|
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[PolyForm Noncommercial 1.0.0](LICENSE) — Free for noncommercial use. For commercial licenses, please contact us.
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::captcha::{CaptchaQuery, CaptchaResponse};
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/auth/captcha",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authGetCaptcha",
|
|
||||||
summary = "Get captcha image",
|
|
||||||
description = "Generate a one-time captcha image and store the plaintext captcha in the current session. Captchas are used for sensitive entry points such as login and sending registration email codes. Set rsa=true to return the current session RSA public key at the same time and reduce frontend initialization requests. The captcha is consumed after either successful or failed validation, so clients must fetch a new one after failure.",
|
|
||||||
params(
|
|
||||||
("w" = u32, Query, description = "Captcha image width; allowed range is 80..=400.", example = 160),
|
|
||||||
("h" = u32, Query, description = "Captcha image height; allowed range is 30..=200.", example = 64),
|
|
||||||
("dark" = bool, Query, description = "Whether to generate a dark-mode captcha.", example = false),
|
|
||||||
("rsa" = bool, Query, description = "Whether to include the RSA public key in the response.", example = true)
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Captcha generated successfully. The base64 field is image data that can be used directly as img.src.", body = ApiResponse<CaptchaResponse>),
|
|
||||||
(status = 400, description = "Invalid captcha size.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Session write failed or RSA initialization failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
query: web::Query<CaptchaQuery>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let data = service
|
|
||||||
.auth
|
|
||||||
.auth_captcha(&session, query.into_inner())
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::change_password::ChangePasswordParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/password/change",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authChangePassword",
|
|
||||||
request_body(content = ChangePasswordParams, description = "Password change parameters (passwords encrypted with session RSA public key)", content_type = "application/json"),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Password changed successfully", body = ApiEmptyResponse),
|
|
||||||
(status = 400, description = "Invalid password", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn change_password(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
params: web::Json<ChangePasswordParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.auth
|
|
||||||
.auth_change_password(&session, params.into_inner())
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("password changed successfully")))
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::totp::Disable2FAParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/2fa/disable",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authDisableTwoFactor",
|
|
||||||
summary = "Disable two-factor authentication",
|
|
||||||
description = "Disable TOTP two-factor authentication for the current signed-in user. This requires verifying both the current password and a valid TOTP code or backup code. password must be encrypted with the current session RSA public key; a successfully verified backup code is consumed.",
|
|
||||||
request_body(
|
|
||||||
content = Disable2FAParams,
|
|
||||||
description = "TOTP/backup code and the current password encrypted with RSA.",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "2FA has been disabled.", body = ApiEmptyResponse),
|
|
||||||
(status = 400, description = "2FA is not enabled, the verification code is incorrect, the password is incorrect, or RSA decryption failed.", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Database write failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
params: web::Json<Disable2FAParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.auth
|
|
||||||
.auth_2fa_disable(&session, params.into_inner())
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("two-factor authentication disabled")))
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::totp::Enable2FAResponse;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/2fa/enable",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authPrepareTwoFactorEnable",
|
|
||||||
summary = "Initialize two-factor authentication setup",
|
|
||||||
description = "Generate a new TOTP secret, otpauth QR-code URI, and 10 one-time backup codes for the current signed-in user, and save them in a not-yet-enabled state. Clients must guide the user to scan the QR code and call /auth/2fa/verify with a dynamic code before 2FA is actually enabled. Backup codes are returned in plaintext only once in this response; frontends must remind users to store them securely.",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "2FA setup initialized successfully. Returns the secret, QR-code URI, and backup codes.", body = ApiResponse<Enable2FAResponse>),
|
|
||||||
(status = 400, description = "The current user has already enabled 2FA.", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Database write or backup code hashing failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let data = service.auth.auth_2fa_enable(&session).await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::totp::Get2FAStatusResponse;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/auth/2fa/status",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authGetTwoFactorStatus",
|
|
||||||
summary = "Get two-factor authentication status",
|
|
||||||
description = "Read the current signed-in user's TOTP two-factor authentication status, including whether it is enabled, the authentication method, and whether backup codes are still available.",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Read successfully.", body = ApiResponse<Get2FAStatusResponse>),
|
|
||||||
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let data = service.auth.auth_2fa_status(&session).await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::email::EmailResponse;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/auth/email",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authGetPrimaryEmail",
|
|
||||||
summary = "Get current user verified email",
|
|
||||||
description = "Return the verified primary email for the current signed-in user. If no verified email is bound to the account, the email field is null.",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Read successfully.", body = ApiResponse<EmailResponse>),
|
|
||||||
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let data = service.auth.auth_get_email(&session).await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::login::LoginParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/login",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authLogin",
|
|
||||||
summary = "Account login",
|
|
||||||
description = "Log in using a username or verified email. password must be a Base64 ciphertext encrypted with the public key returned by /auth/rsa; the first login attempt must include captcha. If the account has TOTP enabled, the first successful password check returns 400/two-factor required and records pending verification state in the session. Then submit username, password, and totp_code again in the same session to complete login. On success, the session is renewed, the current user is bound, and temporary RSA keys are cleared.",
|
|
||||||
request_body(
|
|
||||||
content = LoginParams,
|
|
||||||
description = "Login parameters. username accepts a username or email; password is an RSA-OAEP-SHA256 encrypted ciphertext; captcha is the captcha stored in the current session; totp_code is required only during the second verification step.",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Login succeeded. The server establishes login state through the session cookie.", body = ApiEmptyResponse),
|
|
||||||
(status = 400, description = "Captcha error, RSA decryption failure, or missing/incorrect TOTP.", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "User does not exist or password is incorrect; to reduce enumeration risk, incorrect passwords are also treated as user-not-found.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Database, cache, or session write failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
params: web::Json<LoginParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.auth
|
|
||||||
.auth_login(params.into_inner(), session)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("login successful")))
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/logout",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authLogout",
|
|
||||||
summary = "Log out",
|
|
||||||
description = "Clear the user identity and all temporary authentication data from the current session, including captcha, temporary RSA keys, and pending 2FA state. This endpoint is idempotent: unauthenticated users also receive a success response.",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Logged out successfully, or the session was already unauthenticated.", body = ApiEmptyResponse),
|
|
||||||
(status = 500, description = "Session persistence failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service.auth.auth_logout(&session).await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("logout successful")))
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::me::ContextMe;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/auth/me",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authGetCurrentUser",
|
|
||||||
summary = "Get current signed-in user context",
|
|
||||||
description = "Return the current user's basic profile, preferred language, timezone, and notification summary using the user_uid bound to the session. This endpoint is typically used to restore the login state when the frontend app starts.",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "The current session is authenticated. Returns the user context.", body = ApiResponse<ContextMe>),
|
|
||||||
(status = 401, description = "The current session is unauthenticated or the login state has expired.", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "The user in the session no longer exists, has been disabled, or has been deleted.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let data = service.auth.auth_me(session).await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
pub mod captcha;
|
|
||||||
pub mod change_password;
|
|
||||||
pub mod disable_2fa;
|
|
||||||
pub mod enable_2fa;
|
|
||||||
pub mod get_2fa_status;
|
|
||||||
pub mod get_email;
|
|
||||||
pub mod login;
|
|
||||||
pub mod logout;
|
|
||||||
pub mod me;
|
|
||||||
pub mod regenerate_2fa_backup_codes;
|
|
||||||
pub mod register;
|
|
||||||
pub mod register_email_code;
|
|
||||||
pub mod request_email_change;
|
|
||||||
pub mod request_reset_password;
|
|
||||||
pub mod rsa;
|
|
||||||
pub mod verify_2fa;
|
|
||||||
pub mod verify_email;
|
|
||||||
pub mod verify_reset_password;
|
|
||||||
|
|
||||||
use actix_web::web;
|
|
||||||
|
|
||||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
|
||||||
cfg.service(
|
|
||||||
web::scope("/auth")
|
|
||||||
.route("/rsa", web::get().to(rsa::handle))
|
|
||||||
.route("/captcha", web::get().to(captcha::handle))
|
|
||||||
.route("/login", web::post().to(login::handle))
|
|
||||||
.route("/logout", web::post().to(logout::handle))
|
|
||||||
.route("/me", web::get().to(me::handle))
|
|
||||||
.route(
|
|
||||||
"/register/email-code",
|
|
||||||
web::post().to(register_email_code::handle),
|
|
||||||
)
|
|
||||||
.route("/register", web::post().to(register::handle))
|
|
||||||
.route("/email", web::get().to(get_email::handle))
|
|
||||||
.route(
|
|
||||||
"/email/change",
|
|
||||||
web::post().to(request_email_change::handle),
|
|
||||||
)
|
|
||||||
.route("/email/verify", web::post().to(verify_email::handle))
|
|
||||||
.route(
|
|
||||||
"/reset-password",
|
|
||||||
web::post().to(request_reset_password::handle),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/reset-password/verify",
|
|
||||||
web::post().to(verify_reset_password::handle),
|
|
||||||
)
|
|
||||||
.route("/2fa/status", web::get().to(get_2fa_status::handle))
|
|
||||||
.route("/2fa/enable", web::post().to(enable_2fa::handle))
|
|
||||||
.route("/2fa/verify", web::post().to(verify_2fa::handle))
|
|
||||||
.route("/2fa/disable", web::post().to(disable_2fa::handle))
|
|
||||||
.route(
|
|
||||||
"/2fa/backup-codes/regenerate",
|
|
||||||
web::post().to(regenerate_2fa_backup_codes::handle),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/password/change",
|
|
||||||
web::post().to(change_password::change_password),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
|
||||||
pub struct Regenerate2FABackupCodesRequest {
|
|
||||||
/// Current account password encrypted with the session RSA public key.
|
|
||||||
pub password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
|
||||||
pub struct Regenerate2FABackupCodesResponse {
|
|
||||||
/// Newly generated one-time backup codes. Old backup codes become invalid.
|
|
||||||
pub backup_codes: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/2fa/backup-codes/regenerate",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authRegenerateTwoFactorBackupCodes",
|
|
||||||
summary = "Regenerate 2FA backup codes",
|
|
||||||
description = "After verifying the current password, generate a new set of backup codes for a user with 2FA enabled and replace the old backup codes. password must be encrypted with the current session RSA public key. Backup codes are returned in plaintext only once in this response; clients must prompt users to store them securely.",
|
|
||||||
request_body(
|
|
||||||
content = Regenerate2FABackupCodesRequest,
|
|
||||||
description = "The current account password encrypted with RSA.",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Backup codes have been regenerated; old backup codes are immediately invalidated.", body = ApiResponse<Regenerate2FABackupCodesResponse>),
|
|
||||||
(status = 400, description = "2FA is not enabled, the password is incorrect, or RSA decryption failed.", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Database write or backup code hashing failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
params: web::Json<Regenerate2FABackupCodesRequest>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let backup_codes = service
|
|
||||||
.auth
|
|
||||||
.auth_2fa_regenerate_backup_codes(&session, params.into_inner().password)
|
|
||||||
.await?;
|
|
||||||
Ok(
|
|
||||||
HttpResponse::Ok().json(ApiResponse::new(Regenerate2FABackupCodesResponse {
|
|
||||||
backup_codes,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Serialize;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::users::User;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::register::RegisterParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
|
||||||
pub struct RegisterResponse {
|
|
||||||
/// Newly created user id.
|
|
||||||
pub id: Uuid,
|
|
||||||
/// Unique username used for login and profile URL.
|
|
||||||
pub username: String,
|
|
||||||
/// Display name initialized from username.
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
/// Avatar URL; usually absent right after registration.
|
|
||||||
pub avatar_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<User> for RegisterResponse {
|
|
||||||
fn from(user: User) -> Self {
|
|
||||||
Self {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
display_name: user.display_name,
|
|
||||||
avatar_url: user.avatar_url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/register",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authRegister",
|
|
||||||
summary = "Register a new account",
|
|
||||||
description = "Create an account after validating username, email, password, captcha, and email verification code. password must be encrypted with the current session RSA public key; captcha and email_code are one-time credentials. On successful registration, the new user is written to the session and does not need to log in again.",
|
|
||||||
request_body(
|
|
||||||
content = RegisterParams,
|
|
||||||
description = "Registration parameters. email_code comes from /auth/register/email-code; password is a Base64 ciphertext encrypted with RSA-OAEP-SHA256.",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Registration succeeded; the current session is automatically signed in as the new user.", body = ApiResponse<RegisterResponse>),
|
|
||||||
(status = 400, description = "Captcha error, email verification code error, weak password, RSA decryption failure, or missing required fields.", body = ApiErrorResponse),
|
|
||||||
(status = 409, description = "The username or email is already in use.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Database transaction, password hashing, cache, or session write failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
params: web::Json<RegisterParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user = service
|
|
||||||
.auth
|
|
||||||
.auth_register(params.into_inner(), &session)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(RegisterResponse::from(user))))
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::register::{RegisterEmailCodeParams, RegisterEmailCodeResponse};
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/register/email-code",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authSendRegisterEmailCode",
|
|
||||||
summary = "Send registration email verification code",
|
|
||||||
description = "After validating the captcha in the current session, send a 6-digit registration code to the target email address. The endpoint checks whether a verified email already exists and applies a per-email cooldown to prevent email bombing. The code is valid for 10 minutes by default.",
|
|
||||||
request_body(
|
|
||||||
content = RegisterEmailCodeParams,
|
|
||||||
description = "The target email address and captcha from the current session.",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "The verification email has been queued for delivery. Returns the code expiration time.", body = ApiResponse<RegisterEmailCodeResponse>),
|
|
||||||
(status = 400, description = "The captcha is incorrect, the email is empty, or requests are too frequent.", body = ApiErrorResponse),
|
|
||||||
(status = 409, description = "The email is already used by another verified account.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Cache write failed or the email service is unavailable.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
params: web::Json<RegisterEmailCodeParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let data = service
|
|
||||||
.auth
|
|
||||||
.auth_register_email_code(params.into_inner(), &session)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::email::EmailChangeRequest;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/email/change",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authRequestEmailChange",
|
|
||||||
summary = "Request login email change",
|
|
||||||
description = "After verifying the current user password, send a confirmation link to the new email address. password must be encrypted with the current session RSA public key. The token in the confirmation link is valid for 1 hour by default; the actual email switch is completed by calling /auth/email/verify.",
|
|
||||||
request_body(
|
|
||||||
content = EmailChangeRequest,
|
|
||||||
description = "The new email address and encrypted current account password.",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "The confirmation email has been queued for delivery.", body = ApiEmptyResponse),
|
|
||||||
(status = 400, description = "The new email is empty, the password is incorrect, or RSA decryption failed.", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
|
||||||
(status = 409, description = "The new email is already in use.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Cache, email service, or database read failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
params: web::Json<EmailChangeRequest>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.auth
|
|
||||||
.auth_email_change_request(&session, params.into_inner())
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("email change verification sent")))
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::reset_pass::ResetPasswordRequest;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/reset-password",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authRequestPasswordReset",
|
|
||||||
summary = "Request password reset email",
|
|
||||||
description = "Submit an email address to send a password reset link if it belongs to an active user. To prevent user enumeration, the business logic attempts to return success whether the email exists, rate limits are triggered, or email delivery fails. Internally, the endpoint enforces a 60-second cooldown and a daily limit of 5 requests per email.",
|
|
||||||
request_body(
|
|
||||||
content = ResetPasswordRequest,
|
|
||||||
description = "The email address that should receive the password reset link.",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "The request has been accepted; if the email exists, a reset email will be sent.", body = ApiEmptyResponse),
|
|
||||||
(status = 500, description = "Rare unrecoverable server-side error.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
params: web::Json<ResetPasswordRequest>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.auth
|
|
||||||
.auth_reset_password_request(params.into_inner())
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("password reset request accepted")))
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::rsa::RsaResponse;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/auth/rsa",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authGetRsaPublicKey",
|
|
||||||
summary = "Get login form RSA public key",
|
|
||||||
description = "Generate or reuse a temporary RSA-2048 key pair for the current browser session and return the public key in PKCS#1 PEM format. Clients should use this public key to encrypt sensitive fields such as passwords with RSA-OAEP-SHA256 before submitting login, registration, password reset, or 2FA disable requests. The private key is encrypted with AEAD and stored only in the server-side session; it is never returned to clients.",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Return the RSA public key available for the current session; if an unexpired key already exists in the session, reuse the existing public key.", body = ApiResponse<RsaResponse>),
|
|
||||||
(status = 500, description = "APP_SESSION_SECRET is missing, RSA generation failed, or session write failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let data = service.auth.auth_rsa(&session).await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::totp::Verify2FAParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/2fa/verify",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authVerifyAndEnableTwoFactor",
|
|
||||||
summary = "Verify and enable two-factor authentication",
|
|
||||||
description = "After initializing with /auth/2fa/enable, submit the 6-digit TOTP code generated by the authenticator app. On success, the current user's 2FA status is set to enabled. A small clock drift of one 30-second window before or after is allowed.",
|
|
||||||
request_body(
|
|
||||||
content = Verify2FAParams,
|
|
||||||
description = "The 6-digit TOTP code generated by the authenticator app.",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "2FA has been enabled.", body = ApiEmptyResponse),
|
|
||||||
(status = 400, description = "2FA has not been initialized, is already enabled, or the verification code is incorrect.", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Database write failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
params: web::Json<Verify2FAParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.auth
|
|
||||||
.auth_2fa_verify_and_enable(&session, params.into_inner())
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("two-factor authentication enabled")))
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::email::EmailVerifyRequest;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/email/verify",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authVerifyEmailChange",
|
|
||||||
summary = "Confirm email change",
|
|
||||||
description = "Complete an email change using the token from the confirmation email. The endpoint checks again whether the target email is already taken, then marks old emails as unverified and inserts the new verified primary email in a transaction.",
|
|
||||||
request_body(
|
|
||||||
content = EmailVerifyRequest,
|
|
||||||
description = "Email change confirmation token.",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Email changed successfully.", body = ApiEmptyResponse),
|
|
||||||
(status = 400, description = "The token is empty.", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "The token is invalid or expired.", body = ApiErrorResponse),
|
|
||||||
(status = 409, description = "The target email was taken by another account before confirmation.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Database transaction failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
params: web::Json<EmailVerifyRequest>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service.auth.auth_email_verify(params.into_inner()).await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("email verified")))
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::auth::reset_pass::ResetPasswordVerifyParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/auth/reset-password/verify",
|
|
||||||
tag = "Auth",
|
|
||||||
operation_id = "authVerifyPasswordReset",
|
|
||||||
summary = "Confirm password reset",
|
|
||||||
description = "Set a new password using the token from the password reset email. password must be encrypted with the current session RSA public key; the new password is strength-checked and rehashed with Argon2id. The token is deleted immediately after successful use; expired or missing tokens fail.",
|
|
||||||
request_body(
|
|
||||||
content = ResetPasswordVerifyParams,
|
|
||||||
description = "The reset token and new password encrypted with RSA.",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Password reset succeeded.", body = ApiEmptyResponse),
|
|
||||||
(status = 400, description = "The token is invalid or expired, RSA decryption failed, or the password is too weak.", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Database update or password hashing failed.", body = ApiErrorResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn handle(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
params: web::Json<ResetPasswordVerifyParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.auth
|
|
||||||
.auth_reset_password_verify(&session, params.into_inner())
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("password reset successful")))
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::channels::ChannelCategory;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::service::im::categories::CreateCategoryParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/categories",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imCategoryCreate",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = CreateCategoryParams,
|
|
||||||
description = "Category creation parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Category created successfully", body = ApiResponse<ChannelCategory>),
|
|
||||||
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn category_create(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<CreateCategoryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
let result = service
|
|
||||||
.im
|
|
||||||
.category_create(&im_session, &path.workspace_name, params.into_inner())
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Created().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub category_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a category
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/categories/{category_id}",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imCategoryDelete",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Category deleted successfully", body = ApiEmptyResponse),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or category not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn category_delete(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
service
|
|
||||||
.im
|
|
||||||
.category_delete(&im_session, &path.workspace_name, path.category_id)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("Category deleted")))
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::channels::ChannelCategory;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List categories
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/categories",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imCategoryList",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Categories listed successfully", body = ApiResponse<Vec<ChannelCategory>>),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn category_list(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
let result = service
|
|
||||||
.im
|
|
||||||
.category_list(&im_session, &path.workspace_name)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::channels::ChannelCategory;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::service::im::categories::UpdateCategoryParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub category_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a category
|
|
||||||
#[utoipa::path(
|
|
||||||
put,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/categories/{category_id}",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imCategoryUpdate",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = UpdateCategoryParams,
|
|
||||||
description = "Category update parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Category updated successfully", body = ApiResponse<ChannelCategory>),
|
|
||||||
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or category not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn category_update(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<UpdateCategoryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
let result = service
|
|
||||||
.im
|
|
||||||
.category_update(
|
|
||||||
&im_session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.category_id,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::base_info::resolve_users;
|
|
||||||
use crate::models::channels::ChannelDetail;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::service::im::channels::CreateChannelParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a channel
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/channels",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imChannelCreate",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = CreateChannelParams,
|
|
||||||
description = "Channel creation parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Channel created successfully", body = ApiResponse<ChannelDetail>),
|
|
||||||
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn channel_create(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<CreateChannelParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
let request_id = uuid::Uuid::now_v7();
|
|
||||||
let channel = service
|
|
||||||
.im
|
|
||||||
.channel_create(
|
|
||||||
&im_session,
|
|
||||||
&path.workspace_name,
|
|
||||||
params.into_inner(),
|
|
||||||
request_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let db = &service.ctx.db;
|
|
||||||
let users = resolve_users(db, &[channel.created_by]).await?;
|
|
||||||
let creator = users.get(&channel.created_by).cloned().unwrap_or_default();
|
|
||||||
let detail = channel.into_detail(creator);
|
|
||||||
|
|
||||||
Ok(HttpResponse::Created().json(ApiResponse::new(detail)))
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub channel_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a channel
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imChannelDelete",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Channel deleted successfully", body = ApiEmptyResponse),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn channel_delete(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
let request_id = uuid::Uuid::now_v7();
|
|
||||||
service
|
|
||||||
.im
|
|
||||||
.channel_delete(
|
|
||||||
&im_session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.channel_id,
|
|
||||||
request_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("Channel deleted")))
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::base_info::resolve_users;
|
|
||||||
use crate::models::channels::ChannelDetail;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub channel_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a channel
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imChannelGet",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Channel retrieved successfully", body = ApiResponse<ChannelDetail>),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn channel_get(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
let channel = service
|
|
||||||
.im
|
|
||||||
.channel_get(&im_session, &path.workspace_name, path.channel_id)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let db = &service.ctx.db;
|
|
||||||
let users = resolve_users(db, &[channel.created_by]).await?;
|
|
||||||
let creator = users.get(&channel.created_by).cloned().unwrap_or_default();
|
|
||||||
let detail = channel.into_detail(creator);
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(detail)))
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::base_info::resolve_users;
|
|
||||||
use crate::models::channels::ChannelDetail;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::service::im::channels::ChannelListFilters;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
pub channel_type: Option<String>,
|
|
||||||
pub channel_kind: Option<String>,
|
|
||||||
pub category_id: Option<uuid::Uuid>,
|
|
||||||
pub archived: Option<bool>,
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List channels
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/channels",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imChannelList",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Channels listed successfully", body = ApiResponse<Vec<ChannelDetail>>),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn channel_list(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
let filters = ChannelListFilters {
|
|
||||||
channel_type: query.channel_type.clone(),
|
|
||||||
channel_kind: query.channel_kind.clone(),
|
|
||||||
category_id: query.category_id,
|
|
||||||
archived: query.archived,
|
|
||||||
};
|
|
||||||
let result = service
|
|
||||||
.im
|
|
||||||
.channel_list(
|
|
||||||
&im_session,
|
|
||||||
&path.workspace_name,
|
|
||||||
filters,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let db = &service.ctx.db;
|
|
||||||
let creator_ids: Vec<Uuid> = result.iter().map(|c| c.created_by).collect();
|
|
||||||
let users = resolve_users(db, &creator_ids).await?;
|
|
||||||
let details: Vec<ChannelDetail> = result
|
|
||||||
.into_iter()
|
|
||||||
.map(|c| {
|
|
||||||
let creator = users.get(&c.created_by).cloned().unwrap_or_default();
|
|
||||||
c.into_detail(creator)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(details)))
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::base_info::resolve_users;
|
|
||||||
use crate::models::channels::ChannelDetail;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::service::im::channels::UpdateChannelParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub channel_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a channel
|
|
||||||
#[utoipa::path(
|
|
||||||
put,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imChannelUpdate",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = UpdateChannelParams,
|
|
||||||
description = "Channel update parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Channel updated successfully", body = ApiResponse<ChannelDetail>),
|
|
||||||
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn channel_update(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<UpdateChannelParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
let request_id = uuid::Uuid::now_v7();
|
|
||||||
let channel = service
|
|
||||||
.im
|
|
||||||
.channel_update(
|
|
||||||
&im_session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.channel_id,
|
|
||||||
params.into_inner(),
|
|
||||||
request_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let db = &service.ctx.db;
|
|
||||||
let users = resolve_users(db, &[channel.created_by]).await?;
|
|
||||||
let creator = users.get(&channel.created_by).cloned().unwrap_or_default();
|
|
||||||
let detail = channel.into_detail(creator);
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(detail)))
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::channels::ChannelMember;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::service::im::members::InviteMemberParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub channel_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Invite a member
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imMemberInvite",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = InviteMemberParams,
|
|
||||||
description = "Invitation parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Member invited successfully", body = ApiResponse<ChannelMember>),
|
|
||||||
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn member_invite(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<InviteMemberParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
let result = service
|
|
||||||
.im
|
|
||||||
.member_invite(
|
|
||||||
&im_session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.channel_id,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Created().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::channels::ChannelMember;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub channel_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Join a channel
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/join",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imMemberJoin",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Joined channel successfully", body = ApiResponse<ChannelMember>),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn member_join(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
let result = service
|
|
||||||
.im
|
|
||||||
.member_join(&im_session, &path.workspace_name, path.channel_id)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub channel_id: uuid::Uuid,
|
|
||||||
pub user_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Kick a member
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members/{user_id}",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imMemberKick",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Member kicked successfully", body = ApiEmptyResponse),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace, channel or member not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn member_kick(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
service
|
|
||||||
.im
|
|
||||||
.member_kick(
|
|
||||||
&im_session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.channel_id,
|
|
||||||
path.user_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("Member kicked")))
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub channel_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Leave a channel
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/leave",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imMemberLeave",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Left channel successfully", body = ApiEmptyResponse),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn member_leave(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
service
|
|
||||||
.im
|
|
||||||
.member_leave(&im_session, &path.workspace_name, path.channel_id)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("Left channel")))
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::channels::ChannelMember;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub channel_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List channel members
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imMemberList",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Members listed successfully", body = ApiResponse<Vec<ChannelMember>>),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn member_list(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
let result = service
|
|
||||||
.im
|
|
||||||
.member_list(
|
|
||||||
&im_session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.channel_id,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::channels::ChannelMember;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::im::ImSession;
|
|
||||||
use crate::service::im::members::UpdateMemberParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub channel_id: uuid::Uuid,
|
|
||||||
pub user_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update member role
|
|
||||||
#[utoipa::path(
|
|
||||||
put,
|
|
||||||
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members/{user_id}",
|
|
||||||
tag = "IM",
|
|
||||||
operation_id = "imMemberUpdate",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = UpdateMemberParams,
|
|
||||||
description = "Member update parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Member updated successfully", body = ApiResponse<ChannelMember>),
|
|
||||||
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace, channel or member not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn member_update(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<UpdateMemberParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
let im_session = ImSession::new(user_id);
|
|
||||||
let result = service
|
|
||||||
.im
|
|
||||||
.member_update(
|
|
||||||
&im_session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.channel_id,
|
|
||||||
path.user_id,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
pub mod category_create;
|
|
||||||
pub mod category_delete;
|
|
||||||
pub mod category_list;
|
|
||||||
pub mod category_update;
|
|
||||||
pub mod channel_create;
|
|
||||||
pub mod channel_delete;
|
|
||||||
pub mod channel_get;
|
|
||||||
pub mod channel_list;
|
|
||||||
pub mod channel_update;
|
|
||||||
pub mod member_invite;
|
|
||||||
pub mod member_join;
|
|
||||||
pub mod member_kick;
|
|
||||||
pub mod member_leave;
|
|
||||||
pub mod member_list;
|
|
||||||
pub mod member_update;
|
|
||||||
|
|
||||||
use actix_web::web;
|
|
||||||
|
|
||||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
|
||||||
cfg.service(
|
|
||||||
web::scope("/im/workspaces/{workspace_name}")
|
|
||||||
// Channels
|
|
||||||
.route("/channels", web::get().to(channel_list::channel_list))
|
|
||||||
.route("/channels", web::post().to(channel_create::channel_create))
|
|
||||||
.route(
|
|
||||||
"/channels/{channel_id}",
|
|
||||||
web::get().to(channel_get::channel_get),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/channels/{channel_id}",
|
|
||||||
web::put().to(channel_update::channel_update),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/channels/{channel_id}",
|
|
||||||
web::delete().to(channel_delete::channel_delete),
|
|
||||||
)
|
|
||||||
// Members
|
|
||||||
.route(
|
|
||||||
"/channels/{channel_id}/members",
|
|
||||||
web::get().to(member_list::member_list),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/channels/{channel_id}/members",
|
|
||||||
web::post().to(member_invite::member_invite),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/channels/{channel_id}/members/{user_id}",
|
|
||||||
web::put().to(member_update::member_update),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/channels/{channel_id}/members/{user_id}",
|
|
||||||
web::delete().to(member_kick::member_kick),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/channels/{channel_id}/join",
|
|
||||||
web::post().to(member_join::member_join),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/channels/{channel_id}/leave",
|
|
||||||
web::post().to(member_leave::member_leave),
|
|
||||||
)
|
|
||||||
// Categories
|
|
||||||
.route("/categories", web::get().to(category_list::category_list))
|
|
||||||
.route(
|
|
||||||
"/categories",
|
|
||||||
web::post().to(category_create::category_create),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/categories/{category_id}",
|
|
||||||
web::put().to(category_update::category_update),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/categories/{category_id}",
|
|
||||||
web::delete().to(category_delete::category_delete),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use crate::api::response::ApiResponse;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
|
||||||
pub struct IssueTokenRequest {
|
|
||||||
pub user_id: String,
|
|
||||||
pub scopes: Vec<String>,
|
|
||||||
pub ttl_hours: Option<i64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub extra: HashMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
|
||||||
pub struct IssueTokenResponse {
|
|
||||||
pub access_token: String,
|
|
||||||
pub refresh_token: String,
|
|
||||||
pub expires_at: i64,
|
|
||||||
pub key_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/internal/tokens",
|
|
||||||
tag = "Internal",
|
|
||||||
operation_id = "internalIssueToken",
|
|
||||||
request_body = IssueTokenRequest,
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "JWT token issued", body = ApiResponse<IssueTokenResponse>),
|
|
||||||
(status = 401, description = "Authentication required"),
|
|
||||||
(status = 403, description = "Admin permission required"),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn issue_token(
|
|
||||||
session: Session,
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
body: web::Json<IssueTokenRequest>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let _user_uid = session.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
|
|
||||||
let ttl_secs = body.ttl_hours.unwrap_or(1) * 3600;
|
|
||||||
|
|
||||||
let tokens = service
|
|
||||||
.internal_auth
|
|
||||||
.issue_token(
|
|
||||||
&body.user_id,
|
|
||||||
ttl_secs,
|
|
||||||
body.scopes.clone(),
|
|
||||||
body.extra.clone(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(IssueTokenResponse {
|
|
||||||
access_token: tokens.access_token,
|
|
||||||
refresh_token: tokens.refresh_token,
|
|
||||||
expires_at: tokens.expires_at,
|
|
||||||
key_id: tokens.key_id,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
pub mod issue_api_key;
|
|
||||||
|
|
||||||
use actix_web::web;
|
|
||||||
|
|
||||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
|
||||||
cfg.service(
|
|
||||||
web::scope("/internal")
|
|
||||||
.route("/tokens", web::post().to(issue_api_key::issue_token)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueAssignee;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
/// User ID (UUID) to assign
|
|
||||||
pub user_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Assign a user to an issue
|
|
||||||
///
|
|
||||||
/// Assigns a workspace member to the given issue.
|
|
||||||
/// Requires write access to the issue (author or workspace member).
|
|
||||||
///
|
|
||||||
/// Effects:
|
|
||||||
/// - User is assigned to the issue
|
|
||||||
/// - Assignee is automatically subscribed to the issue
|
|
||||||
/// - Issue assignee count is incremented
|
|
||||||
///
|
|
||||||
/// Returns the created assignment record.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/assignees/{user_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueAssign",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "User assigned successfully. Returns the created assignment record.", body = ApiResponse<IssueAssignee>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Issue or user not found", body = ApiErrorResponse),
|
|
||||||
(status = 409, description = "User is already assigned to this issue", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn assign_issue(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let assignee = service
|
|
||||||
.issue
|
|
||||||
.issue_assign(&session, &path.workspace_name, path.number, path.user_id)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Created().json(ApiResponse::new(assignee)))
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueLabelRelation;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
/// Label ID (UUID) to assign
|
|
||||||
pub label_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Assign a label to an issue
|
|
||||||
///
|
|
||||||
/// Attaches a label to the given issue. The label must belong to a repository in the same workspace.
|
|
||||||
/// Requires write access to the issue (author or workspace member).
|
|
||||||
///
|
|
||||||
/// Effects:
|
|
||||||
/// - Label is attached to the issue
|
|
||||||
/// - Issue label count is incremented
|
|
||||||
///
|
|
||||||
/// Returns the created label relation.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/labels/{label_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueAssignLabel",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Label assigned successfully. Returns the created label relation.", body = ApiResponse<IssueLabelRelation>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Issue or label not found", body = ApiErrorResponse),
|
|
||||||
(status = 409, description = "Label is already assigned to this issue", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn assign_label(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let rel = service
|
|
||||||
.issue
|
|
||||||
.issue_assign_label(&session, &path.workspace_name, path.number, path.label_id)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(rel)))
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::Issue;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close an issue
|
|
||||||
///
|
|
||||||
/// Closes an open issue. The issue is marked as closed and the closing user is recorded.
|
|
||||||
/// Requires write access to the issue (author or workspace member).
|
|
||||||
///
|
|
||||||
/// Effects:
|
|
||||||
/// - Issue state changes to "closed"
|
|
||||||
/// - Closed by and closed at are recorded
|
|
||||||
/// - A "Closed" event is logged
|
|
||||||
///
|
|
||||||
/// Returns the closed issue with updated metadata.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/close",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueClose",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Issue closed successfully. Returns the closed issue with updated metadata.", body = ApiResponse<Issue>),
|
|
||||||
(status = 400, description = "Issue is already closed", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to close this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn close(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let issue = service
|
|
||||||
.issue
|
|
||||||
.issue_close(&session, &path.workspace_name, path.number)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::base_info::{self, UserBaseInfo};
|
|
||||||
use crate::models::issues::IssueDetail;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::issues::core::CreateIssueParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create an issue
|
|
||||||
///
|
|
||||||
/// Creates a new issue in the specified workspace.
|
|
||||||
/// Requires at least Member role in the workspace.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// - title: Issue title (required)
|
|
||||||
/// - body: Issue body in markdown (optional)
|
|
||||||
/// - priority: Priority level (optional, defaults to "none")
|
|
||||||
/// - visibility: Visibility setting (optional, defaults to "public")
|
|
||||||
/// - due_at: Due date (optional)
|
|
||||||
/// - repo_ids: Related repository IDs
|
|
||||||
/// - label_ids: Label IDs to apply
|
|
||||||
/// - assignee_ids: User IDs to assign
|
|
||||||
/// - milestone_id: Milestone ID to attach
|
|
||||||
///
|
|
||||||
/// Effects:
|
|
||||||
/// - Issue is created with auto-incrementing number
|
|
||||||
/// - Author is automatically subscribed
|
|
||||||
/// - Relations, labels, and assignees are attached
|
|
||||||
/// - Workspace stats are updated
|
|
||||||
///
|
|
||||||
/// Returns the created issue with full metadata.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueCreate",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = CreateIssueParams,
|
|
||||||
description = "Issue creation parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Issue created successfully. Returns the newly created issue with full metadata.", body = ApiResponse<IssueDetail>),
|
|
||||||
(status = 400, description = "Invalid parameters: empty title, invalid repository/label/milestone references", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or referenced resource not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn create(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<CreateIssueParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let issue = service
|
|
||||||
.issue
|
|
||||||
.issue_create(&session, &path.workspace_name, params.into_inner())
|
|
||||||
.await?;
|
|
||||||
let author_id = issue.author_id;
|
|
||||||
let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?;
|
|
||||||
let author = users
|
|
||||||
.get(&author_id)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| UserBaseInfo::placeholder(author_id));
|
|
||||||
Ok(HttpResponse::Created().json(ApiResponse::new(issue.into_detail(author))))
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::base_info::{self, UserBaseInfo};
|
|
||||||
use crate::models::issues::IssueCommentDetail;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::issues::comments::CreateCommentParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a comment on an issue
|
|
||||||
///
|
|
||||||
/// Adds a new comment to an issue. Users with read access can comment unless the issue is locked
|
|
||||||
/// (in which case only users with write access can comment).
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// - body: Comment body in markdown format (required)
|
|
||||||
/// - reply_to_comment_id: ID of parent comment for threaded replies (optional)
|
|
||||||
///
|
|
||||||
/// Effects:
|
|
||||||
/// - Comment is created and attached to the issue
|
|
||||||
/// - Commenter is automatically subscribed to the issue
|
|
||||||
/// - Issue comment count is incremented
|
|
||||||
///
|
|
||||||
/// Returns the created comment with metadata.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueCreateComment",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(content = CreateCommentParams, description = "Comment creation parameters", content_type = "application/json"),
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Comment created successfully.", body = ApiResponse<IssueCommentDetail>),
|
|
||||||
(status = 400, description = "Invalid parameters: empty body or issue is locked", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions (issue locked and user lacks write access)", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn create_comment(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<CreateCommentParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let comment = service
|
|
||||||
.issue
|
|
||||||
.issue_create_comment(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let author_id = comment.author_id;
|
|
||||||
let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?;
|
|
||||||
let author = users
|
|
||||||
.get(&author_id)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| UserBaseInfo::placeholder(author_id));
|
|
||||||
Ok(HttpResponse::Created().json(ApiResponse::new(comment.into_detail(author))))
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueLabel;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::issues::labels::CreateLabelParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub repo_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a label
|
|
||||||
///
|
|
||||||
/// Creates a new issue label in a repository.
|
|
||||||
/// Requires at least Member role in the repository.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// - name: Label name (required, e.g., "bug", "feature")
|
|
||||||
/// - color: Hex color code (required, e.g., "#FF0000")
|
|
||||||
/// - description: Label description (optional)
|
|
||||||
///
|
|
||||||
/// Returns the created label with metadata.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueCreateLabel",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(content = CreateLabelParams, description = "Label creation parameters", content_type = "application/json"),
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Label created successfully.", body = ApiResponse<IssueLabel>),
|
|
||||||
(status = 400, description = "Invalid parameters: empty name or invalid color", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions (requires Member role)", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn create_label(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<CreateLabelParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let label = service
|
|
||||||
.issue
|
|
||||||
.issue_create_label(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
&path.repo_name,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Created().json(ApiResponse::new(label)))
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueMilestone;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::issues::milestones::CreateMilestoneParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Repository name (unique within the workspace)
|
|
||||||
pub repo_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a milestone
|
|
||||||
///
|
|
||||||
/// Creates a new milestone in a repository for tracking issue progress.
|
|
||||||
/// Requires at least Member role in the repository.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// - title: Milestone title (required)
|
|
||||||
/// - description: Description of the milestone (optional)
|
|
||||||
/// - due_at: Target due date (optional)
|
|
||||||
///
|
|
||||||
/// Returns the created milestone with metadata.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueCreateMilestone",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = CreateMilestoneParams,
|
|
||||||
description = "Milestone creation parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Milestone created successfully. Returns the newly created milestone with metadata.", body = ApiResponse<IssueMilestone>),
|
|
||||||
(status = 400, description = "Invalid parameters: empty title", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn create_milestone(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<CreateMilestoneParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let milestone = service
|
|
||||||
.issue
|
|
||||||
.issue_create_milestone(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
&path.repo_name,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Created().json(ApiResponse::new(milestone)))
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete an issue
|
|
||||||
///
|
|
||||||
/// Soft-deletes an issue. The issue is marked as deleted but remains in the database.
|
|
||||||
/// Requires Admin role in the workspace (or issue author).
|
|
||||||
///
|
|
||||||
/// Effects:
|
|
||||||
/// - Issue is marked as deleted (soft-delete)
|
|
||||||
/// - Workspace issue count is decremented
|
|
||||||
///
|
|
||||||
/// Returns success message on completion.
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueDelete",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Issue deleted successfully.", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions (requires Admin role or issue author)", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn delete(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.issue
|
|
||||||
.issue_delete(&session, &path.workspace_name, path.number)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Issue deleted successfully".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub number: i64,
|
|
||||||
pub comment_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete an issue comment
|
|
||||||
///
|
|
||||||
/// Soft-deletes a comment. The comment author can delete their own comments.
|
|
||||||
/// Workspace admins can delete any comment.
|
|
||||||
///
|
|
||||||
/// Effects:
|
|
||||||
/// - Comment is marked as deleted
|
|
||||||
/// - Issue comment count is decremented
|
|
||||||
///
|
|
||||||
/// Returns success message on completion.
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments/{comment_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueDeleteComment",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Comment deleted successfully.", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Cannot delete other users' comments (requires admin)", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace, issue, or comment not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn delete_comment(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.issue
|
|
||||||
.issue_delete_comment(&session, &path.workspace_name, path.number, path.comment_id)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Comment deleted successfully".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub repo_name: String,
|
|
||||||
pub label_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a label
|
|
||||||
///
|
|
||||||
/// Permanently removes an issue label from a repository.
|
|
||||||
/// Requires Admin role in the repository.
|
|
||||||
///
|
|
||||||
/// Effects:
|
|
||||||
/// - Label is permanently deleted
|
|
||||||
/// - All issue-label relations using this label are removed
|
|
||||||
///
|
|
||||||
/// Returns success message on completion.
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels/{label_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueDeleteLabel",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Label deleted successfully.", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Repository, workspace, or label not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn delete_label(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.issue
|
|
||||||
.issue_delete_label(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
&path.repo_name,
|
|
||||||
path.label_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Label deleted successfully".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Repository name (unique within the workspace)
|
|
||||||
pub repo_name: String,
|
|
||||||
/// Milestone ID (UUID)
|
|
||||||
pub milestone_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a milestone
|
|
||||||
///
|
|
||||||
/// Permanently removes a milestone from the repository.
|
|
||||||
/// Requires Admin role in the repository.
|
|
||||||
///
|
|
||||||
/// Effects:
|
|
||||||
/// - Milestone is permanently deleted
|
|
||||||
/// - Issues attached to this milestone lose their milestone association
|
|
||||||
///
|
|
||||||
/// Returns success message on completion.
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones/{milestone_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueDeleteMilestone",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Milestone deleted successfully.", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Repository, workspace, or milestone not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn delete_milestone(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.issue
|
|
||||||
.issue_delete_milestone(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
&path.repo_name,
|
|
||||||
path.milestone_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Milestone deleted".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::base_info::{self, UserBaseInfo};
|
|
||||||
use crate::models::issues::IssueDetail;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get an issue by number
|
|
||||||
///
|
|
||||||
/// Returns detailed information about a specific issue, identified by workspace name and issue number.
|
|
||||||
/// Requires read access to the issue (public or workspace member).
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueGet",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Issue retrieved successfully. Returns complete issue with all metadata.", body = ApiResponse<IssueDetail>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn get(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let issue = service
|
|
||||||
.issue
|
|
||||||
.issue_get(&session, &path.workspace_name, path.number)
|
|
||||||
.await?;
|
|
||||||
let author_id = issue.author_id;
|
|
||||||
let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?;
|
|
||||||
let author = users
|
|
||||||
.get(&author_id)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| UserBaseInfo::placeholder(author_id));
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(issue.into_detail(author))))
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::base_info::{self, UserBaseInfo};
|
|
||||||
use crate::models::issues::IssueDetail;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::issues::core::IssueListFilters;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
/// Filter by issue state ("open" or "closed")
|
|
||||||
pub state: Option<String>,
|
|
||||||
/// Filter by priority level
|
|
||||||
pub priority: Option<String>,
|
|
||||||
/// Filter by author user ID
|
|
||||||
pub author_id: Option<uuid::Uuid>,
|
|
||||||
/// Filter by assignee user ID
|
|
||||||
pub assignee_id: Option<uuid::Uuid>,
|
|
||||||
/// Filter by milestone ID
|
|
||||||
pub milestone_id: Option<uuid::Uuid>,
|
|
||||||
/// Filter by label ID
|
|
||||||
pub label_id: Option<uuid::Uuid>,
|
|
||||||
/// Maximum number of issues to return (default: 50, max: 100)
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
/// Number of issues to skip for pagination (default: 0)
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List issues in a workspace
|
|
||||||
///
|
|
||||||
/// Returns a paginated list of issues in the workspace, sorted by issue number (newest first).
|
|
||||||
/// Supports filtering by state, priority, author, assignee, milestone, and label.
|
|
||||||
/// Only returns issues visible to the authenticated user (public + workspace member access).
|
|
||||||
/// Requires authentication.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueList",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Issues listed successfully. Returns filtered array of issue objects with metadata.", body = ApiResponse<Vec<IssueDetail>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let filters = IssueListFilters {
|
|
||||||
state: query.state.clone(),
|
|
||||||
priority: query.priority.clone(),
|
|
||||||
author_id: query.author_id,
|
|
||||||
assignee_id: query.assignee_id,
|
|
||||||
milestone_id: query.milestone_id,
|
|
||||||
label_id: query.label_id,
|
|
||||||
};
|
|
||||||
let issues = service
|
|
||||||
.issue
|
|
||||||
.issue_list(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
filters,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let user_ids: Vec<_> = issues.iter().map(|i| i.author_id).collect();
|
|
||||||
let users = base_info::resolve_users(&service.ctx.db, &user_ids).await?;
|
|
||||||
let details: Vec<IssueDetail> = issues
|
|
||||||
.into_iter()
|
|
||||||
.map(|i| {
|
|
||||||
let author = users
|
|
||||||
.get(&i.author_id)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| UserBaseInfo::placeholder(i.author_id));
|
|
||||||
i.into_detail(author)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(details)))
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueAssignee;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
/// Maximum number of assignees to return (default: 50, max: 100)
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
/// Number of assignees to skip for pagination (default: 0)
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List assignees of an issue
|
|
||||||
///
|
|
||||||
/// Returns a paginated list of all users assigned to the given issue.
|
|
||||||
/// Shows who is assigned, when they were assigned, and who assigned them.
|
|
||||||
/// Requires read access to the issue.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/assignees",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueListAssignees",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Assignees listed successfully. Returns array of assignee objects with assignment metadata.", body = ApiResponse<Vec<IssueAssignee>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list_assignees(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let assignees = service
|
|
||||||
.issue
|
|
||||||
.issue_assignees(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(assignees)))
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::base_info::{self, UserBaseInfo};
|
|
||||||
use crate::models::issues::IssueCommentDetail;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List issue comments
|
|
||||||
///
|
|
||||||
/// Returns a paginated list of comments on an issue, sorted by creation date (oldest first).
|
|
||||||
/// Requires read access to the issue.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueListComments",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Comments listed successfully.", body = ApiResponse<Vec<IssueCommentDetail>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn list_comments(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let comments = service
|
|
||||||
.issue
|
|
||||||
.issue_comments(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let user_ids: Vec<_> = comments.iter().map(|c| c.author_id).collect();
|
|
||||||
let users = base_info::resolve_users(&service.ctx.db, &user_ids).await?;
|
|
||||||
let details: Vec<IssueCommentDetail> = comments
|
|
||||||
.into_iter()
|
|
||||||
.map(|c| {
|
|
||||||
let author = users
|
|
||||||
.get(&c.author_id)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| UserBaseInfo::placeholder(c.author_id));
|
|
||||||
c.into_detail(author)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(details)))
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueEvent;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
/// Maximum number of events to return (default: 50, max: 100)
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
/// Number of events to skip for pagination (default: 0)
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List issue events
|
|
||||||
///
|
|
||||||
/// Returns a chronological timeline of all events for the given issue.
|
|
||||||
/// Events include creation, updates, state changes, assignments, label changes, etc.
|
|
||||||
/// Sorted by creation date (oldest first for timeline display).
|
|
||||||
/// Requires read access to the issue.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/events",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueListEvents",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Events listed successfully. Returns chronological array of event objects.", body = ApiResponse<Vec<IssueEvent>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list_events(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let events = service
|
|
||||||
.issue
|
|
||||||
.issue_list_events(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(events)))
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueLabelRelation;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
/// Maximum number of label relations to return (default: 50, max: 100)
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
/// Number of label relations to skip for pagination (default: 0)
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List labels assigned to an issue
|
|
||||||
///
|
|
||||||
/// Returns a paginated list of all label relations for the given issue.
|
|
||||||
/// Shows which labels are attached to the issue, with assignment metadata.
|
|
||||||
/// Requires read access to the issue.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/labels",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueListLabelRelations",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Label relations listed successfully. Returns array of label relation objects with metadata.", body = ApiResponse<Vec<IssueLabelRelation>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list_issue_labels(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let rels = service
|
|
||||||
.issue
|
|
||||||
.issue_label_relations(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(rels)))
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueLabel;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub repo_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List labels in a repository
|
|
||||||
///
|
|
||||||
/// Returns all issue labels defined in the repository, sorted alphabetically.
|
|
||||||
/// Requires read access to the repository.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueListLabels",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Labels listed successfully.", body = ApiResponse<Vec<IssueLabel>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn list_labels(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let labels = service
|
|
||||||
.issue
|
|
||||||
.issue_labels(&session, &path.workspace_name, &path.repo_name)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(labels)))
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueMilestone;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Repository name (unique within the workspace)
|
|
||||||
pub repo_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
/// Maximum number of milestones to return (default: 50, max: 100)
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
/// Number of milestones to skip for pagination (default: 0)
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List milestones in a repository
|
|
||||||
///
|
|
||||||
/// Returns a paginated list of milestones in the repository, sorted by state (open first) then by due date.
|
|
||||||
/// Includes milestone metadata such as title, description, state, due date, and progress.
|
|
||||||
/// Requires read access to the repository.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueListMilestones",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Milestones listed successfully. Returns array of milestone objects with metadata.", body = ApiResponse<Vec<IssueMilestone>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list_milestones(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let milestones = service
|
|
||||||
.issue
|
|
||||||
.issue_milestones(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
&path.repo_name,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(milestones)))
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::Issue;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
|
||||||
pub struct LockIssueParams {
|
|
||||||
/// Whether to lock (true) or unlock (false) the issue
|
|
||||||
pub locked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lock or unlock an issue
|
|
||||||
///
|
|
||||||
/// Locks or unlocks conversation on an issue. When locked, only users with write access can comment.
|
|
||||||
/// Requires write access to the issue (author or workspace member).
|
|
||||||
///
|
|
||||||
/// Returns the updated issue with lock status.
|
|
||||||
#[utoipa::path(
|
|
||||||
put,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/lock",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueLock",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = LockIssueParams,
|
|
||||||
description = "Lock/unlock parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Issue lock status updated successfully.", body = ApiResponse<Issue>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to manage this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn lock(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<LockIssueParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let issue = service
|
|
||||||
.issue
|
|
||||||
.issue_lock(&session, &path.workspace_name, path.number, params.locked)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
pub mod assign_issue;
|
|
||||||
pub mod assign_label;
|
|
||||||
pub mod close;
|
|
||||||
pub mod create;
|
|
||||||
pub mod create_comment;
|
|
||||||
pub mod create_label;
|
|
||||||
pub mod create_milestone;
|
|
||||||
pub mod delete;
|
|
||||||
pub mod delete_comment;
|
|
||||||
pub mod delete_label;
|
|
||||||
pub mod delete_milestone;
|
|
||||||
pub mod get;
|
|
||||||
pub mod list;
|
|
||||||
pub mod list_assignees;
|
|
||||||
pub mod list_comments;
|
|
||||||
pub mod list_events;
|
|
||||||
pub mod list_issue_labels;
|
|
||||||
pub mod list_labels;
|
|
||||||
pub mod list_milestones;
|
|
||||||
pub mod lock;
|
|
||||||
pub mod pr_relations;
|
|
||||||
pub mod reactions;
|
|
||||||
pub mod reopen;
|
|
||||||
pub mod repo_relations;
|
|
||||||
pub mod subscribers;
|
|
||||||
pub mod templates;
|
|
||||||
pub mod transfer;
|
|
||||||
pub mod unassign_issue;
|
|
||||||
pub mod unassign_label;
|
|
||||||
pub mod update;
|
|
||||||
pub mod update_comment;
|
|
||||||
pub mod update_label;
|
|
||||||
pub mod update_milestone;
|
|
||||||
|
|
||||||
use actix_web::web;
|
|
||||||
|
|
||||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
|
||||||
cfg.service(
|
|
||||||
web::scope("")
|
|
||||||
// Core
|
|
||||||
.route("", web::get().to(list::list))
|
|
||||||
.route("", web::post().to(create::create))
|
|
||||||
.route("/{number}", web::get().to(get::get))
|
|
||||||
.route("/{number}", web::put().to(update::update))
|
|
||||||
.route("/{number}", web::delete().to(delete::delete))
|
|
||||||
.route("/{number}/close", web::post().to(close::close))
|
|
||||||
.route("/{number}/reopen", web::post().to(reopen::reopen))
|
|
||||||
.route("/{number}/lock", web::put().to(lock::lock))
|
|
||||||
.route("/{number}/transfer", web::post().to(transfer::transfer))
|
|
||||||
// Comments
|
|
||||||
.route(
|
|
||||||
"/{number}/comments",
|
|
||||||
web::get().to(list_comments::list_comments),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{number}/comments",
|
|
||||||
web::post().to(create_comment::create_comment),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{number}/comments/{comment_id}",
|
|
||||||
web::put().to(update_comment::update_comment),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{number}/comments/{comment_id}",
|
|
||||||
web::delete().to(delete_comment::delete_comment),
|
|
||||||
)
|
|
||||||
// Labels (issue-level)
|
|
||||||
.route(
|
|
||||||
"/{number}/labels",
|
|
||||||
web::get().to(list_issue_labels::list_issue_labels),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{number}/labels/{label_id}",
|
|
||||||
web::post().to(assign_label::assign_label),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{number}/labels/{label_id}",
|
|
||||||
web::delete().to(unassign_label::unassign_label),
|
|
||||||
)
|
|
||||||
// Assignees
|
|
||||||
.route(
|
|
||||||
"/{number}/assignees",
|
|
||||||
web::get().to(list_assignees::list_assignees),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{number}/assignees/{user_id}",
|
|
||||||
web::post().to(assign_issue::assign_issue),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{number}/assignees/{user_id}",
|
|
||||||
web::delete().to(unassign_issue::unassign_issue),
|
|
||||||
)
|
|
||||||
// Events
|
|
||||||
.route("/{number}/events", web::get().to(list_events::list_events))
|
|
||||||
// Reactions
|
|
||||||
.route(
|
|
||||||
"/{number}/reactions",
|
|
||||||
web::get().to(reactions::list_reactions),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{number}/reactions",
|
|
||||||
web::post().to(reactions::add_reaction),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{number}/reactions/{reaction_id}",
|
|
||||||
web::delete().to(reactions::remove_reaction),
|
|
||||||
)
|
|
||||||
// Subscribers
|
|
||||||
.route(
|
|
||||||
"/{number}/subscribers",
|
|
||||||
web::get().to(subscribers::list_subscribers),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{number}/subscribe",
|
|
||||||
web::post().to(subscribers::subscribe),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{number}/subscribe",
|
|
||||||
web::delete().to(subscribers::unsubscribe),
|
|
||||||
)
|
|
||||||
.route("/{number}/mute", web::put().to(subscribers::mute))
|
|
||||||
// Repo relations
|
|
||||||
.route(
|
|
||||||
"/{number}/repos",
|
|
||||||
web::get().to(repo_relations::list_repo_relations),
|
|
||||||
)
|
|
||||||
.route("/{number}/repos", web::post().to(repo_relations::link_repo))
|
|
||||||
.route(
|
|
||||||
"/{number}/repos/{relation_id}",
|
|
||||||
web::delete().to(repo_relations::unlink_repo),
|
|
||||||
)
|
|
||||||
// PR relations
|
|
||||||
.route(
|
|
||||||
"/{number}/prs",
|
|
||||||
web::get().to(pr_relations::list_pr_relations),
|
|
||||||
)
|
|
||||||
.route("/{number}/prs", web::post().to(pr_relations::link_pr))
|
|
||||||
.route(
|
|
||||||
"/{number}/prs/{relation_id}",
|
|
||||||
web::delete().to(pr_relations::unlink_pr),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn configure_repo_level(cfg: &mut web::ServiceConfig) {
|
|
||||||
cfg.service(
|
|
||||||
web::scope("")
|
|
||||||
.route("/labels", web::get().to(list_labels::list_labels))
|
|
||||||
.route("/labels", web::post().to(create_label::create_label))
|
|
||||||
.route(
|
|
||||||
"/labels/{label_id}",
|
|
||||||
web::put().to(update_label::update_label),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/labels/{label_id}",
|
|
||||||
web::delete().to(delete_label::delete_label),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/milestones",
|
|
||||||
web::get().to(list_milestones::list_milestones),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/milestones",
|
|
||||||
web::post().to(create_milestone::create_milestone),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/milestones/{milestone_id}",
|
|
||||||
web::put().to(update_milestone::update_milestone),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/milestones/{milestone_id}",
|
|
||||||
web::delete().to(delete_milestone::delete_milestone),
|
|
||||||
)
|
|
||||||
.route("/templates", web::get().to(templates::list_templates))
|
|
||||||
.route("/templates", web::post().to(templates::create_template))
|
|
||||||
.route(
|
|
||||||
"/templates/{template_id}",
|
|
||||||
web::put().to(templates::update_template),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/templates/{template_id}",
|
|
||||||
web::delete().to(templates::delete_template),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssuePrRelation;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::issues::pr_relations::LinkPrParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
/// Maximum number of relations to return (default: 50, max: 100)
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
/// Number of relations to skip for pagination (default: 0)
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List pull request relations for an issue
|
|
||||||
///
|
|
||||||
/// Returns a paginated list of all pull requests linked to the given issue.
|
|
||||||
/// Shows relation type (closes, references, depends_on, etc.) and link metadata.
|
|
||||||
/// Requires read access to the issue.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/prs",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueListPrRelations",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "PR relations listed successfully. Returns array of PR relation objects.", body = ApiResponse<Vec<IssuePrRelation>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list_pr_relations(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let relations = service
|
|
||||||
.issue
|
|
||||||
.issue_pr_relations(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(relations)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Link a pull request to an issue
|
|
||||||
///
|
|
||||||
/// Creates a relation between the given issue and a pull request.
|
|
||||||
/// Commonly used to mark a PR as closing or referencing an issue.
|
|
||||||
/// Requires write access to the issue.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// - pull_request_id: Pull request ID (UUID) to link
|
|
||||||
/// - relation_type: Relation type ("closes", "references", "depends_on", default: "references")
|
|
||||||
///
|
|
||||||
/// Returns the created relation.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/prs",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueLinkPr",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = LinkPrParams,
|
|
||||||
description = "Link pull request parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Pull request linked successfully. Returns the created relation.", body = ApiResponse<IssuePrRelation>),
|
|
||||||
(status = 400, description = "Invalid parameters: invalid relation type", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Issue or pull request not found", body = ApiErrorResponse),
|
|
||||||
(status = 409, description = "Pull request is already linked to this issue", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn link_pr(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<LinkPrParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let relation = service
|
|
||||||
.issue
|
|
||||||
.issue_link_pr(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(relation)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct RelationPathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
/// Relation ID (UUID)
|
|
||||||
pub relation_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unlink a pull request from an issue
|
|
||||||
///
|
|
||||||
/// Removes a pull request relation from the given issue.
|
|
||||||
/// Requires write access to the issue.
|
|
||||||
///
|
|
||||||
/// Returns success message on completion.
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/prs/{relation_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueUnlinkPr",
|
|
||||||
params(RelationPathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Pull request unlinked successfully.", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "PR relation not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn unlink_pr(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<RelationPathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.issue
|
|
||||||
.issue_unlink_pr(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
path.relation_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("PR unlinked".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueReaction;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::issues::reactions::CreateIssueReactionParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
/// Maximum number of reactions to return (default: 50, max: 100)
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
/// Number of reactions to skip for pagination (default: 0)
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List reactions on an issue
|
|
||||||
///
|
|
||||||
/// Returns a paginated list of all emoji reactions on the given issue.
|
|
||||||
/// Includes reaction content, target type, and user who added each reaction.
|
|
||||||
/// Requires read access to the issue.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reactions",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueListReactions",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Reactions listed successfully. Returns array of reaction objects.", body = ApiResponse<Vec<IssueReaction>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list_reactions(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let reactions = service
|
|
||||||
.issue
|
|
||||||
.issue_reactions(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(reactions)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a reaction to an issue
|
|
||||||
///
|
|
||||||
/// Adds an emoji reaction to the given issue.
|
|
||||||
/// Requires read access to the issue.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// - content: Reaction content (e.g., "👍", "❤️", "🎉")
|
|
||||||
/// - target_type: Target type for the reaction (defaults to "Issue")
|
|
||||||
/// - target_id: Target ID for reactions on specific comments (optional)
|
|
||||||
///
|
|
||||||
/// Returns the created reaction.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reactions",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueAddReaction",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = CreateIssueReactionParams,
|
|
||||||
description = "Reaction creation parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Reaction added successfully. Returns the created reaction.", body = ApiResponse<IssueReaction>),
|
|
||||||
(status = 400, description = "Invalid parameters: empty content or invalid target type", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn add_reaction(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<CreateIssueReactionParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let reaction = service
|
|
||||||
.issue
|
|
||||||
.issue_add_reaction(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Created().json(ApiResponse::new(reaction)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct ReactionPathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
/// Reaction ID (UUID)
|
|
||||||
pub reaction_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a reaction from an issue
|
|
||||||
///
|
|
||||||
/// Removes a previously added reaction. Only the user who added the reaction can remove it.
|
|
||||||
/// Requires read access to the issue.
|
|
||||||
///
|
|
||||||
/// Returns success message on completion.
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reactions/{reaction_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueRemoveReaction",
|
|
||||||
params(ReactionPathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Reaction removed successfully.", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Cannot remove another user's reaction", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Reaction not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn remove_reaction(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<ReactionPathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.issue
|
|
||||||
.issue_remove_reaction(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
path.reaction_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Reaction removed".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::Issue;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reopen an issue
|
|
||||||
///
|
|
||||||
/// Reopens a closed issue. The issue state changes back to "open" and closed metadata is cleared.
|
|
||||||
/// Requires write access to the issue (author or workspace member).
|
|
||||||
///
|
|
||||||
/// Returns the reopened issue with updated metadata.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reopen",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueReopen",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Issue reopened successfully. Returns the reopened issue with updated metadata.", body = ApiResponse<Issue>),
|
|
||||||
(status = 400, description = "Issue is not closed", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to reopen this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn reopen(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let issue = service
|
|
||||||
.issue
|
|
||||||
.issue_reopen(&session, &path.workspace_name, path.number)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueRepoRelation;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::issues::repo_relations::LinkRepoParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
/// Maximum number of relations to return (default: 50, max: 100)
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
/// Number of relations to skip for pagination (default: 0)
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List repository relations for an issue
|
|
||||||
///
|
|
||||||
/// Returns a paginated list of all repositories linked to the given issue.
|
|
||||||
/// Shows relation type (references, duplicates, blocks, etc.) and link metadata.
|
|
||||||
/// Requires read access to the issue.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/repos",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueListRepoRelations",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Repository relations listed successfully. Returns array of relation objects.", body = ApiResponse<Vec<IssueRepoRelation>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list_repo_relations(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let relations = service
|
|
||||||
.issue
|
|
||||||
.issue_repo_relations(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(relations)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Link a repository to an issue
|
|
||||||
///
|
|
||||||
/// Creates a relation between the given issue and a repository.
|
|
||||||
/// Requires write access to the issue.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// - repo_id: Repository ID (UUID) to link
|
|
||||||
/// - relation_type: Relation type ("references", "duplicates", "blocks", "depends_on", default: "references")
|
|
||||||
///
|
|
||||||
/// Returns the created relation.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/repos",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueLinkRepo",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = LinkRepoParams,
|
|
||||||
description = "Link repository parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Repository linked successfully. Returns the created relation.", body = ApiResponse<IssueRepoRelation>),
|
|
||||||
(status = 400, description = "Invalid parameters: invalid relation type", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Issue or repository not found", body = ApiErrorResponse),
|
|
||||||
(status = 409, description = "Repository is already linked to this issue", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn link_repo(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<LinkRepoParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let relation = service
|
|
||||||
.issue
|
|
||||||
.issue_link_repo(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(relation)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct RelationPathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
/// Relation ID (UUID)
|
|
||||||
pub relation_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unlink a repository from an issue
|
|
||||||
///
|
|
||||||
/// Removes a repository relation from the given issue.
|
|
||||||
/// Requires write access to the issue.
|
|
||||||
///
|
|
||||||
/// Returns success message on completion.
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/repos/{relation_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueUnlinkRepo",
|
|
||||||
params(RelationPathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Repository unlinked successfully.", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Repository relation not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn unlink_repo(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<RelationPathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.issue
|
|
||||||
.issue_unlink_repo(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
path.relation_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Repo unlinked".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueSubscriber;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
/// Maximum number of subscribers to return (default: 50, max: 100)
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
/// Number of subscribers to skip for pagination (default: 0)
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List subscribers of an issue
|
|
||||||
///
|
|
||||||
/// Returns a paginated list of all users subscribed to the given issue.
|
|
||||||
/// Shows who receives notifications and their subscription reason (author, assignee, manual).
|
|
||||||
/// Requires read access to the issue.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/subscribers",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueListSubscribers",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Subscribers listed successfully. Returns array of subscriber objects.", body = ApiResponse<Vec<IssueSubscriber>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list_subscribers(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let subscribers = service
|
|
||||||
.issue
|
|
||||||
.issue_subscribers(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(subscribers)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Subscribe to an issue
|
|
||||||
///
|
|
||||||
/// Subscribes the authenticated user to the given issue to receive notifications.
|
|
||||||
/// Requires read access to the issue.
|
|
||||||
///
|
|
||||||
/// Effects:
|
|
||||||
/// - User is added as a subscriber with "manual" reason
|
|
||||||
/// - User receives notifications for all issue activity
|
|
||||||
///
|
|
||||||
/// Returns the created subscription record.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/subscribe",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueSubscribe",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Subscribed successfully. Returns the subscription record.", body = ApiResponse<IssueSubscriber>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 409, description = "Already subscribed to this issue", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn subscribe(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let sub = service
|
|
||||||
.issue
|
|
||||||
.issue_subscribe(&session, &path.workspace_name, path.number)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(sub)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unsubscribe from an issue
|
|
||||||
///
|
|
||||||
/// Removes the authenticated user's subscription to the given issue.
|
|
||||||
/// Stops all notifications for this issue.
|
|
||||||
///
|
|
||||||
/// Returns success message on completion.
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/subscribe",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueUnsubscribe",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Unsubscribed successfully.", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Not currently subscribed to this issue", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn unsubscribe(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.issue
|
|
||||||
.issue_unsubscribe(&session, &path.workspace_name, path.number)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Unsubscribed".to_string())))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
|
||||||
pub struct MuteIssueParams {
|
|
||||||
/// Whether to mute (true) or unmute (false) notifications
|
|
||||||
pub muted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mute or unmute issue notifications
|
|
||||||
///
|
|
||||||
/// Mutes or unmutes notifications for the given issue without unsubscribing.
|
|
||||||
/// Requires an active subscription to the issue.
|
|
||||||
///
|
|
||||||
/// Returns success message on completion.
|
|
||||||
#[utoipa::path(
|
|
||||||
put,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/mute",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueMute",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = MuteIssueParams,
|
|
||||||
description = "Mute/unmute parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Mute status updated successfully.", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Not currently subscribed to this issue", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn mute(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<MuteIssueParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.issue
|
|
||||||
.issue_mute(&session, &path.workspace_name, path.number, params.muted)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Mute status updated".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueTemplate;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::issues::templates::{CreateTemplateParams, UpdateTemplateParams};
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Repository name (unique within the workspace)
|
|
||||||
pub repo_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
/// Maximum number of templates to return (default: 50, max: 100)
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
/// Number of templates to skip for pagination (default: 0)
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List issue templates in a repository
|
|
||||||
///
|
|
||||||
/// Returns a paginated list of all active issue templates in the repository.
|
|
||||||
/// Templates provide pre-filled content for creating new issues.
|
|
||||||
/// Sorted alphabetically by name.
|
|
||||||
/// Requires read access to the repository.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueListTemplates",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Templates listed successfully. Returns array of template objects.", body = ApiResponse<Vec<IssueTemplate>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list_templates(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let templates = service
|
|
||||||
.issue
|
|
||||||
.issue_templates(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
&path.repo_name,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(templates)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create an issue template
|
|
||||||
///
|
|
||||||
/// Creates a new issue template in the repository.
|
|
||||||
/// Requires at least Member role in the repository.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// - name: Template name (required)
|
|
||||||
/// - description: Template description (optional)
|
|
||||||
/// - title_template: Default title for issues (optional, supports placeholders)
|
|
||||||
/// - body_template: Default body content in markdown (required)
|
|
||||||
/// - labels: List of label names to auto-apply (optional)
|
|
||||||
///
|
|
||||||
/// Returns the created template with metadata.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueCreateTemplate",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = CreateTemplateParams,
|
|
||||||
description = "Template creation parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Template created successfully. Returns the newly created template.", body = ApiResponse<IssueTemplate>),
|
|
||||||
(status = 400, description = "Invalid parameters: empty name or body template", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn create_template(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<CreateTemplateParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let template = service
|
|
||||||
.issue
|
|
||||||
.issue_create_template(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
&path.repo_name,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Created().json(ApiResponse::new(template)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct TemplatePathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Repository name (unique within the workspace)
|
|
||||||
pub repo_name: String,
|
|
||||||
/// Template ID (UUID)
|
|
||||||
pub template_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update an issue template
|
|
||||||
///
|
|
||||||
/// Updates an existing issue template's metadata and content.
|
|
||||||
/// Requires Admin role in the repository.
|
|
||||||
///
|
|
||||||
/// All fields are optional; only provided fields are updated.
|
|
||||||
/// Returns the updated template.
|
|
||||||
#[utoipa::path(
|
|
||||||
put,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates/{template_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueUpdateTemplate",
|
|
||||||
params(TemplatePathParams),
|
|
||||||
request_body(
|
|
||||||
content = UpdateTemplateParams,
|
|
||||||
description = "Template update parameters (all fields optional)",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Template updated successfully. Returns the updated template.", body = ApiResponse<IssueTemplate>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Repository, workspace, or template not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn update_template(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<TemplatePathParams>,
|
|
||||||
params: web::Json<UpdateTemplateParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let template = service
|
|
||||||
.issue
|
|
||||||
.issue_update_template(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
&path.repo_name,
|
|
||||||
path.template_id,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(template)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete an issue template
|
|
||||||
///
|
|
||||||
/// Permanently removes an issue template from the repository.
|
|
||||||
/// Requires Admin role in the repository.
|
|
||||||
///
|
|
||||||
/// Returns success message on completion.
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates/{template_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueDeleteTemplate",
|
|
||||||
params(TemplatePathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Template deleted successfully.", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Template not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn delete_template(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<TemplatePathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.issue
|
|
||||||
.issue_delete_template(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
&path.repo_name,
|
|
||||||
path.template_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Template deleted".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::Issue;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
|
||||||
pub struct TransferIssueParams {
|
|
||||||
/// Target workspace name to transfer the issue to
|
|
||||||
pub target_workspace_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transfer an issue to another workspace
|
|
||||||
///
|
|
||||||
/// Moves an issue from the current workspace to a different workspace.
|
|
||||||
/// Requires Admin role in both the source and target workspaces.
|
|
||||||
///
|
|
||||||
/// Effects:
|
|
||||||
/// - Issue is transferred to the target workspace with a new number
|
|
||||||
/// - Source workspace issue count is decremented
|
|
||||||
/// - Target workspace issue count is incremented
|
|
||||||
///
|
|
||||||
/// Returns the transferred issue with updated workspace and number.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/transfer",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueTransfer",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = TransferIssueParams,
|
|
||||||
description = "Transfer parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Issue transferred successfully. Returns the issue with new workspace assignment.", body = ApiResponse<Issue>),
|
|
||||||
(status = 400, description = "Invalid target workspace", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions in source or target workspace", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn transfer(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<TransferIssueParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let issue = service
|
|
||||||
.issue
|
|
||||||
.issue_transfer(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
¶ms.target_workspace_name,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
/// User ID (UUID) to unassign
|
|
||||||
pub user_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unassign a user from an issue
|
|
||||||
///
|
|
||||||
/// Removes a user from the issue's assignee list.
|
|
||||||
/// Requires write access to the issue (author or workspace member).
|
|
||||||
///
|
|
||||||
/// Effects:
|
|
||||||
/// - User is removed from the issue's assignees
|
|
||||||
/// - Issue assignee count is decremented
|
|
||||||
///
|
|
||||||
/// Returns success message on completion.
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/assignees/{user_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueUnassign",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "User unassigned successfully.", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "User is not assigned to this issue or not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn unassign_issue(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.issue
|
|
||||||
.issue_unassign(&session, &path.workspace_name, path.number, path.user_id)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("User unassigned".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
/// Label ID (UUID) to unassign
|
|
||||||
pub label_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unassign a label from an issue
|
|
||||||
///
|
|
||||||
/// Removes a label from the given issue.
|
|
||||||
/// Requires write access to the issue (author or workspace member).
|
|
||||||
///
|
|
||||||
/// Effects:
|
|
||||||
/// - Label relation is removed from the issue
|
|
||||||
/// - Issue label count is decremented
|
|
||||||
///
|
|
||||||
/// Returns success message on completion.
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/labels/{label_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueUnassignLabel",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Label unassigned successfully.", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Label is not assigned to this issue or not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn unassign_label(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.issue
|
|
||||||
.issue_unassign_label(&session, &path.workspace_name, path.number, path.label_id)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Label unassigned".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::Issue;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::issues::core::UpdateIssueParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Issue number (unique within the workspace)
|
|
||||||
pub number: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update an issue
|
|
||||||
///
|
|
||||||
/// Updates an existing issue's metadata such as title, body, priority, visibility, due date, and milestone.
|
|
||||||
/// Requires write access to the issue (author or workspace member).
|
|
||||||
///
|
|
||||||
/// All fields are optional; only provided fields are updated.
|
|
||||||
/// Returns the updated issue with full metadata.
|
|
||||||
#[utoipa::path(
|
|
||||||
put,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueUpdate",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = UpdateIssueParams,
|
|
||||||
description = "Issue update parameters (all fields optional)",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Issue updated successfully. Returns the updated issue with full metadata.", body = ApiResponse<Issue>),
|
|
||||||
(status = 400, description = "Invalid parameters: invalid priority, visibility, or milestone", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace, issue, or referenced resource not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn update(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<UpdateIssueParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let issue = service
|
|
||||||
.issue
|
|
||||||
.issue_update(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueComment;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::issues::comments::UpdateCommentParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub number: i64,
|
|
||||||
pub comment_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update an issue comment
|
|
||||||
///
|
|
||||||
/// Updates the body of an existing comment. Only the comment author can update their own comments.
|
|
||||||
/// Requires read access to the issue.
|
|
||||||
///
|
|
||||||
/// Returns the updated comment with edit timestamp.
|
|
||||||
#[utoipa::path(
|
|
||||||
put,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments/{comment_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueUpdateComment",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(content = UpdateCommentParams, description = "Comment update parameters", content_type = "application/json"),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Comment updated successfully.", body = ApiResponse<IssueComment>),
|
|
||||||
(status = 400, description = "Invalid parameters: empty body", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Cannot edit other users' comments", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Workspace, issue, or comment not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn update_comment(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<UpdateCommentParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let comment = service
|
|
||||||
.issue
|
|
||||||
.issue_update_comment(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
path.number,
|
|
||||||
path.comment_id,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(comment)))
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueLabel;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::issues::labels::UpdateLabelParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub repo_name: String,
|
|
||||||
pub label_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a label
|
|
||||||
///
|
|
||||||
/// Updates an existing issue label's name, color, or description.
|
|
||||||
/// Requires Admin role in the repository.
|
|
||||||
///
|
|
||||||
/// All fields are optional; only provided fields are updated.
|
|
||||||
/// Returns the updated label.
|
|
||||||
#[utoipa::path(
|
|
||||||
put,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels/{label_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueUpdateLabel",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(content = UpdateLabelParams, description = "Label update parameters (all fields optional)", content_type = "application/json"),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Label updated successfully.", body = ApiResponse<IssueLabel>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Repository, workspace, or label not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(("session_cookie" = []))
|
|
||||||
)]
|
|
||||||
pub async fn update_label(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<UpdateLabelParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let label = service
|
|
||||||
.issue
|
|
||||||
.issue_update_label(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
&path.repo_name,
|
|
||||||
path.label_id,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(label)))
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::issues::IssueMilestone;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::issues::milestones::UpdateMilestoneParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
/// Workspace name (unique identifier)
|
|
||||||
pub workspace_name: String,
|
|
||||||
/// Repository name (unique within the workspace)
|
|
||||||
pub repo_name: String,
|
|
||||||
/// Milestone ID (UUID)
|
|
||||||
pub milestone_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a milestone
|
|
||||||
///
|
|
||||||
/// Updates an existing milestone's metadata. Can also close or reopen the milestone via the state field.
|
|
||||||
/// Requires at least Member role in the repository.
|
|
||||||
///
|
|
||||||
/// Updatable fields:
|
|
||||||
/// - title: Milestone title (optional)
|
|
||||||
/// - description: Description (optional)
|
|
||||||
/// - due_at: Target due date (optional)
|
|
||||||
/// - state: State ("open" or "closed") for closing/reopening the milestone (optional)
|
|
||||||
///
|
|
||||||
/// All fields are optional; only provided fields are updated.
|
|
||||||
/// Returns the updated milestone with full metadata.
|
|
||||||
#[utoipa::path(
|
|
||||||
put,
|
|
||||||
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones/{milestone_id}",
|
|
||||||
tag = "Issues",
|
|
||||||
operation_id = "issueUpdateMilestone",
|
|
||||||
params(PathParams),
|
|
||||||
request_body(
|
|
||||||
content = UpdateMilestoneParams,
|
|
||||||
description = "Milestone update parameters (all fields optional)",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Milestone updated successfully. Returns the updated milestone with full metadata.", body = ApiResponse<IssueMilestone>),
|
|
||||||
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Repository, workspace, or milestone not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn update_milestone(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
params: web::Json<UpdateMilestoneParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let milestone = service
|
|
||||||
.issue
|
|
||||||
.issue_update_milestone(
|
|
||||||
&session,
|
|
||||||
&path.workspace_name,
|
|
||||||
&path.repo_name,
|
|
||||||
path.milestone_id,
|
|
||||||
params.into_inner(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(milestone)))
|
|
||||||
}
|
|
||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
pub mod auth;
|
|
||||||
pub mod im;
|
|
||||||
pub mod internal;
|
|
||||||
pub mod issue;
|
|
||||||
pub mod notify;
|
|
||||||
pub mod openapi;
|
|
||||||
pub mod pr;
|
|
||||||
pub mod repo;
|
|
||||||
pub mod response;
|
|
||||||
pub mod routes;
|
|
||||||
pub mod user;
|
|
||||||
pub mod wiki;
|
|
||||||
pub mod workspace;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
/// Clear all notifications (dismiss all)
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/notifications",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationClearAll",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "All notifications cleared", body = ApiResponse<i64>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn clear_all_notifications(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let result = service.notify.clear_all_notifications(&session).await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::notifications::NotificationBlock;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::notify::blocks::CreateBlockParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
/// Create a notification block
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/notifications/blocks",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationCreateBlock",
|
|
||||||
request_body(
|
|
||||||
content = CreateBlockParams,
|
|
||||||
description = "Block creation parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Block created", body = ApiResponse<NotificationBlock>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn create_block(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
params: web::Json<CreateBlockParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let result = service
|
|
||||||
.notify
|
|
||||||
.create_block(&session, params.into_inner())
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Created().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::notifications::NotificationSubscription;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::notify::subscriptions::CreateSubscriptionParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
/// Create a notification subscription
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/notifications/subscriptions",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationCreateSubscription",
|
|
||||||
request_body(
|
|
||||||
content = CreateSubscriptionParams,
|
|
||||||
description = "Subscription creation parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Subscription created", body = ApiResponse<NotificationSubscription>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn create_subscription(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
params: web::Json<CreateSubscriptionParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let result = service
|
|
||||||
.notify
|
|
||||||
.create_subscription(&session, params.into_inner())
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Created().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::notifications::NotificationTemplate;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::service::notify::templates::CreateTemplateParams;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
/// Create a notification template (requires system admin)
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/notifications/templates",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationCreateTemplate",
|
|
||||||
request_body(
|
|
||||||
content = CreateTemplateParams,
|
|
||||||
description = "Template creation parameters",
|
|
||||||
content_type = "application/json"
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Template created", body = ApiResponse<NotificationTemplate>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "System admin access required", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn create_template(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
params: web::Json<CreateTemplateParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let result = service
|
|
||||||
.notify
|
|
||||||
.create_template(&session, params.into_inner())
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Created().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub block_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a notification block
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/notifications/blocks/{block_id}",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationDeleteBlock",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Block deleted", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Block not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn delete_block(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service.notify.delete_block(&session, path.block_id).await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Block deleted".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub notification_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a notification
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/notifications/{notification_id}",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationDelete",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Notification deleted", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Notification not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn delete_notification(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.notify
|
|
||||||
.delete_notification(&session, path.notification_id)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Notification deleted".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub subscription_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a notification subscription
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/notifications/subscriptions/{subscription_id}",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationDeleteSubscription",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Subscription deleted", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Subscription not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn delete_subscription(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.notify
|
|
||||||
.delete_subscription(&session, path.subscription_id)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Subscription deleted".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub template_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a notification template (requires system admin)
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/v1/notifications/templates/{template_id}",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationDeleteTemplate",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Template deleted", body = ApiResponse<String>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "System admin access required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Template not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn delete_template(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
service
|
|
||||||
.notify
|
|
||||||
.delete_template(&session, path.template_id)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new("Template deleted".to_string())))
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::base_info;
|
|
||||||
use crate::models::notifications::NotificationDetail;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub notification_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dismiss a notification
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/v1/notifications/{notification_id}/dismiss",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationDismiss",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Notification dismissed", body = ApiResponse<NotificationDetail>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Notification not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn dismiss_notification(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let notification = service
|
|
||||||
.notify
|
|
||||||
.dismiss_notification(&session, path.notification_id)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let actor = match notification.actor_id {
|
|
||||||
Some(id) => base_info::resolve_users(&service.ctx.db, &[id])
|
|
||||||
.await?
|
|
||||||
.remove(&id),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
let workspace = match notification.workspace_id {
|
|
||||||
Some(id) => base_info::resolve_workspaces(&service.ctx.db, &[id])
|
|
||||||
.await?
|
|
||||||
.remove(&id),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
let repo = match notification.repo_id {
|
|
||||||
Some(id) => base_info::resolve_repos(&service.ctx.db, &[id])
|
|
||||||
.await?
|
|
||||||
.remove(&id),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(
|
|
||||||
notification.into_detail(actor, workspace, repo),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::notifications::NotificationTemplate;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub template_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a notification template by ID (requires system admin)
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/notifications/templates/{template_id}",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationGetTemplate",
|
|
||||||
params(PathParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Template retrieved", body = ApiResponse<NotificationTemplate>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 403, description = "System admin access required", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Template not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn get_template(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let result = service
|
|
||||||
.notify
|
|
||||||
.get_template(&session, path.template_id)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
/// Get unread notification count for the current user
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/notifications/unread-count",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationUnreadCount",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Unread count returned successfully", body = ApiResponse<i64>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn get_unread_count(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let result = service.notify.count_unread(&session).await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::notifications::NotificationBlock;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List notification blocks for the current user
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/notifications/blocks",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationListBlocks",
|
|
||||||
params(QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Blocks listed successfully", body = ApiResponse<Vec<NotificationBlock>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list_blocks(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let result = service
|
|
||||||
.notify
|
|
||||||
.list_blocks(
|
|
||||||
&session,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::notifications::NotificationDelivery;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List notification deliveries for the current user
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/notifications/deliveries",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationListDeliveries",
|
|
||||||
params(QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Deliveries listed successfully", body = ApiResponse<Vec<NotificationDelivery>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list_deliveries(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let result = service
|
|
||||||
.notify
|
|
||||||
.list_deliveries(
|
|
||||||
&session,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::notifications::NotificationDelivery;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct PathParams {
|
|
||||||
pub notification_id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List deliveries for a specific notification
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/notifications/{notification_id}/deliveries",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationListDeliveriesForNotification",
|
|
||||||
params(PathParams, QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Deliveries listed successfully", body = ApiResponse<Vec<NotificationDelivery>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 404, description = "Notification not found", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list_deliveries_for_notification(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<PathParams>,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let result = service
|
|
||||||
.notify
|
|
||||||
.list_deliveries_for_notification(
|
|
||||||
&session,
|
|
||||||
path.notification_id,
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::models::base_info;
|
|
||||||
use crate::models::notifications::NotificationDetail;
|
|
||||||
use crate::service::AppService;
|
|
||||||
use crate::session::Session;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
|
||||||
pub struct QueryParams {
|
|
||||||
pub unread_only: Option<bool>,
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
pub offset: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List notifications for the current user
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/v1/notifications",
|
|
||||||
tag = "Notifications",
|
|
||||||
operation_id = "notificationList",
|
|
||||||
params(QueryParams),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Notifications listed successfully", body = ApiResponse<Vec<NotificationDetail>>),
|
|
||||||
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
),
|
|
||||||
security(
|
|
||||||
("session_cookie" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn list_notifications(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
query: web::Query<QueryParams>,
|
|
||||||
) -> Result<HttpResponse, AppError> {
|
|
||||||
let notifications = service
|
|
||||||
.notify
|
|
||||||
.list_notifications(
|
|
||||||
&session,
|
|
||||||
query.unread_only.unwrap_or(false),
|
|
||||||
query.limit.unwrap_or(50),
|
|
||||||
query.offset.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let actor_ids: Vec<_> = notifications.iter().filter_map(|n| n.actor_id).collect();
|
|
||||||
let workspace_ids: Vec<_> = notifications
|
|
||||||
.iter()
|
|
||||||
.filter_map(|n| n.workspace_id)
|
|
||||||
.collect();
|
|
||||||
let repo_ids: Vec<_> = notifications.iter().filter_map(|n| n.repo_id).collect();
|
|
||||||
|
|
||||||
let actors = base_info::resolve_users(&service.ctx.db, &actor_ids).await?;
|
|
||||||
let workspaces = base_info::resolve_workspaces(&service.ctx.db, &workspace_ids).await?;
|
|
||||||
let repos = base_info::resolve_repos(&service.ctx.db, &repo_ids).await?;
|
|
||||||
|
|
||||||
let details: Vec<NotificationDetail> = notifications
|
|
||||||
.into_iter()
|
|
||||||
.map(|n| {
|
|
||||||
let actor = n.actor_id.and_then(|id| actors.get(&id).cloned());
|
|
||||||
let workspace = n.workspace_id.and_then(|id| workspaces.get(&id).cloned());
|
|
||||||
let repo = n.repo_id.and_then(|id| repos.get(&id).cloned());
|
|
||||||
n.into_detail(actor, workspace, repo)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(details)))
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user