Compare commits

..

30 Commits

Author SHA1 Message Date
zhenyi 931d82cbb9 update mian.rs and openapi.json 2026-06-12 16:55:36 +08:00
zhenyi 5f4e9bdfa7 refactor(grpc): bind TCP listener before etcd registration to prevent connection issues
- Change tokio-stream dependency to include net feature for TcpListenerStream
- Move TCP listener binding before etcd registry initialization in main function
- Pass pre-bound TcpListener to gRPC server instead of just SocketAddr
- Update gRPC server to use serve_with_incoming with TcpListenerStream
- Prevent peers from attempting connections before gRPC server is ready
- Ensure proper error handling for TCP binding failures during startup
2026-06-11 23:07:36 +08:00
zhenyi b797e360c0 feat(registry): add service discovery and health check capabilities
- Integrate tonic-health for gRPC service health monitoring
- Add etcd-based service registration with automatic keep-alive
- Implement dynamic configuration loading from etcd with fallback
- Remove external dependencies from docker-compose for simplified deployment
- Refactor service registration logic with improved lease management
- Add health service to gRPC server with serving status reporting
2026-06-11 22:50:40 +08:00
zhenyi 1ccfd3d626 chore(build): add Docker configuration and update dependency versions
- Update Cargo.toml with shortened version specifications for dependencies
- Add .dockerignore file to exclude unnecessary files from Docker builds
- Create .env.example with comprehensive environment variable configurations
- Add docker-compose.yaml with complete multi-service infrastructure setup
- Add Dockerfile with optimized multi-stage build process using cargo-chef
- Add Dockerfile.fast for faster container builds during development
- Configure
2026-06-11 15:32:18 +08:00
zhenyi dbbfb747a4 feat(auth): replace internal auth with JWT token service
- Replace InternalAuthService with TokenService using JWT tokens
- Add support for token issuance, refresh, verification and revocation
- Implement automatic signing key rotation with Redis storage
- Add database migration checks for indexes and foreign key constraints
- Update gRPC endpoints to use token-based authentication
- Remove deprecated API key based authentication system
- Add JSON Web Token support with HMAC-SHA256 signing
- Implement refresh token handling with automatic rotation
- Add token revocation by JTI and user ID
- Update build configuration to include core proto files
- Migrate database schema to handle token-based authentication
- Add comprehensive token validation and verification logic
2026-06-11 15:08:13 +08:00
zhenyi a0bea36041 fix(db): split notification table DDL to support existing databases
- Remove extended columns (repo_id, issue_id, pull_request_id,
  channel_id, message_id, target_type, target_id, action_url,
  priority, metadata, deleted_at) from CREATE TABLE in 001_init.sql
- Add migration 013_notification_extra_columns.sql that uses
  ALTER TABLE ADD COLUMN IF NOT EXISTS for all extended columns
- Move extended column indexes to the new migration
- Ensures compatibility with databases that already have a basic
  notification table from a previous schema version
2026-06-10 18:53:49 +08:00
zhenyi 63ca1151ae docs: add AGENTS.md development guidelines
- Define code style rules, forbidden patterns, and error handling
- Document security, database, API design, and testing standards
- Include Git workflow and architecture decision records
2026-06-10 18:49:53 +08:00
zhenyi 1000f8a80d chore(infra): add gRPC layer, update protobufs, remove immediate module
- Add gRPC service modules: auth, channel, channel settings, member,
  permission
- Update protobuf definitions and generated code
- Remove immediate/ real-time module (superseded by IM service)
- Update etcd discovery and registration
- Update cache, error, config, and build infrastructure
- Add ADR documentation
- Update OpenAPI spec
2026-06-10 18:49:42 +08:00
zhenyi 9eb77ab98b refactor(models): update data models and remove deprecated IM entities
- Update channel, notification, PR, repo, user, workspace models
- Remove deleted IM models: articles, channel follows, message
  attachments/bookmarks/drafts/edit history/embeds/mentions/pins/
  polls/reactions/threads, saved messages, thread read states
- Add new PR models: review requests, templates
- Add repo release assets model
- Add base_info module for API detail responses
2026-06-10 18:49:37 +08:00
zhenyi 420dedbc1e feat(service): expand service layer with new domain operations
- Add IM service modules: audit, channel roles, custom emojis, forum
  tags, integrations, invitations, repo links, slash commands, stages,
  voice, webhooks
- Add PR service modules: review requests, templates
- Add repo service modules: contributors, release assets, git extras
  (archive, branch rename, commit extras, diff/merge, tag, tree)
- Add user service: social (follow/block)
- Add internal auth service
- Update existing service modules with expanded functionality
- Remove deleted IM modules: articles, delivery trace, drafts,
  follows, messages, polls, presence, reactions, threads
2026-06-10 18:49:32 +08:00
zhenyi cec6dce955 feat(api): expand API endpoints for repo, PR, user, workspace management
- Add git operation endpoints: archive, compare branches, diff, tree,
  repository extras
- Add repo endpoints: contributors, delete fork, get branch/commit
  status/deploy key/invitation/member/release/tag/webhook, topics,
  release assets, webhook deliveries/retry
- Add PR endpoints: review requests, templates
- Add user endpoints: block/unblock, follow/unfollow, presence,
  personal access tokens, account restore
- Add workspace endpoints: billing history, approvals, domains,
  integrations, invitations, members, webhooks, restore
- Add internal API, notification API, IM API modules
- Update route configuration and OpenAPI spec
2026-06-10 18:49:27 +08:00
zhenyi 4586b79cb8 refactor(auth,etcd): reduce nesting depth to comply with 3-level max
- service/auth/login.rs: extract auth_find_user() helper combining
  username + email lookup, reducing login flow from 5 levels to 3
- etcd/register.rs: extract run_keep_alive_stream() and
  renew_lease_and_reregister() from spawn_keep_alive(), reducing
  max nesting from 7 levels to 3
2026-06-10 18:49:15 +08:00
zhenyi e8fa433588 refactor(git): use DEFAULT_REVISION constant across git operations
- Replace 15 occurrences of unwrap_or("HEAD") with
  unwrap_or(DEFAULT_REVISION) across 10 files
- All git API handlers and service methods now reference the shared
  constant from models::common
2026-06-10 18:49:11 +08:00
zhenyi 6205a6de0a refactor(models): replace hardcoded strings with typed enums
- Add ReviewState enum (pending, approved, changes_requested, etc.)
- Add DEFAULT_REVISION constant for git HEAD references
- service/pr/reviews.rs: use ReviewState for review creation and
  submission state validation
- service/pr/core.rs: use MergeStrategyKind for merge strategy
  selection
- service/im/stages.rs: use StagePrivacyLevel for stage creation
- service/im/invitations.rs: use Role enum for invitation role
  defaults
2026-06-10 18:49:06 +08:00
zhenyi 15b875e18d perf(issues): replace N+1 queries with batch operations
- Add Repo::find_by_ids() batch query using WHERE id = ANY($1)
- Replace 3 sequential validation loops (repos, labels, assignees)
  with batch queries using ANY($1)
- Replace 3 sequential INSERT loops with single INSERT...SELECT
  FROM unnest() statements
- Extract 7 helper methods: validate_issue_repos,
  validate_issue_labels, validate_issue_assignees,
  validate_issue_milestone, insert_issue_repo_relations,
  insert_issue_label_relations, insert_issue_assignees
- Reduce issue_create() from ~243 lines to ~80 lines
2026-06-10 18:49:00 +08:00
zhenyi 61dc08c036 refactor(session): extract SessionConfig and add auto-migration
- session/config.rs: add SessionConfig struct that pre-validates all
  session configuration values, with build_middleware() for infallible
  middleware construction
- session/middleware.rs: expose parse_same_site as pub(crate)
- session/storage/redis.rs: derive Clone for RedisSessionStore
- main.rs: validate session config before HttpServer loop, use
  SessionConfig::build_middleware() inside closure; add
  sqlx::migrate!() call after database connection
2026-06-10 18:48:55 +08:00
zhenyi b83a842c6f fix(core): remove unwrap/expect in non-test code
- cache/lru.rs: replace lock().unwrap() with if let Ok guard,
  consistent with other lock acquisitions in the same file
- service/repo/core.rs: replace try_into().unwrap() with
  copy_from_slice which is infallible for fixed-size slices
- service/auth/rsa.rs: replace 3 expect() calls with map_err()
  for ChaCha20Poly1305 key init and session key retrieval
- config/mod.rs: replace GLOBAL_CONFIG.get().expect() with
  unwrap_or_else fallback to empty config
2026-06-10 18:48:49 +08:00
zhenyi d6c468a9fc feat(db): add sqlx migrate feature and renumber migration files
- Add 'migrate' feature to sqlx dependency
- Renumber migrations to fix duplicate version numbers (two 014 files)
- Re-sequence migrations 009-012 for continuous ordering
- Add ALTER TABLE ADD COLUMN IF NOT EXISTS baseline for notification
  table to handle existing databases missing newer columns
- Remove deleted IM migration files (009-012) that were superseded
2026-06-10 18:48:43 +08:00
zhenyi d98e4d59e3 feat(api): implement pull request assignees and check runs endpoints
- Add PR assignees API with list, assign, and unassign operations
- Add PR check runs API with create, update, list, and delete operations
- Implement workspace finding by ID method in core service
- Update .gitignore to include .env* files while preserving .env.example
- Reorder imports in multiple API files for consistency
- Format function calls with proper line breaks across PR-related APIs
- Add wiki revision comparison endpoint with proper schema definitions
- Integrate new API modules into main application setup
- Add health check, readiness probe, and OpenAPI endpoints to main server
- Configure session management and dependency injection in main application
2026-06-07 23:01:05 +08:00
zhenyi 3a22c4265d feat(api): add pull request and wiki API endpoints with OpenAPI generator
- Add gen_openapi binary for generating OpenAPI specification
- Implement comprehensive pull request API endpoints including core operations
- Add pull request reviews, check runs, labels, assignees, and events APIs
- Include pull request status and merge strategy management endpoints
- Add wiki page CRUD operations with revision history and comparison
- Update OpenAPI documentation with Pull Requests and Wiki tags
- Modify workspace find function visibility for external access
- Integrate new API modules into main OpenAPI router configuration
2026-06-07 19:58:02 +08:00
zhenyi b660db7a91 chore(config): remove unused language and model configuration files
- Delete empty language.json file that
2026-06-07 19:42:55 +08:00
zhenyi 4028f0d943 refactor(api): reorder imports and update code formatting across repository endpoints
- Reordered actix-web imports to standardize import order
- Reordered crate module imports to follow alphabetical ordering
- Updated function calls to use multi-line formatting for better readability
- Standardized blank lines around documentation comments
- Applied consistent formatting to response handling methods
- Normalized import organization across all repository-related API files
- Improved code consistency and maintainability through standardized formatting
- Applied formatting updates to all repository endpoint implementations
2026-06-07 19:41:33 +08:00
zhenyi 7368ba676c feat(api): add comprehensive repository management API endpoints
- Introduce new repo module with complete repository functionality
- Add endpoints for repository CRUD operations (create, get, update, archive, delete)
- Implement branch management with create, list, delete and protection features
- Add tag management with create, list and delete operations
- Include release management with create, update and delete capabilities
- Support repository forking with sync functionality
- Implement starring and watching mechanisms for repositories
- Add member management with roles and invitations
- Provide deploy key management for CI/CD integration
- Create webhook management for external integrations
- Implement branch protection rules with approval requirements
- Add commit status and comment functionality for code reviews
- Include merge checking logic for pull requests
- Register all new endpoints in OpenAPI documentation
- Configure routes to handle new repository-specific paths
2026-06-07 19:19:53 +08:00
zhenyi dca717be10 refactor(workspace): pass workspace object instead of id to service methods
- Replace workspace_id parameter with Workspace object reference in all workspace service methods
- Remove redundant find_workspace_by_id calls that were duplicated in each method
- Update all method signatures across approval, audit, billing, branding, core, settings and stats modules
- Modify SQL queries to bind ws.id instead of separate workspace_id parameter
- Add Workspace import to all affected modules
- Adjust method calls in API handlers to pass workspace object instead of id
- Consolidate workspace retrieval logic to single location per operation flow
2026-06-07 18:44:01 +08:00
zhenyi 297a54f312 chore(deps): remove unused utoipa feature auto_into_responses
- Removed auto_into_responses feature from utoipa dependency configuration
- Kept other utoipa features including uuid, chrono, actix_extras, decimal, and macros
- Updated Cargo.toml to reflect the reduced feature set for utoipa crate
2026-06-07 18:09:47 +08:00
zhenyi 0d3b53f7a0 feat(auth): add comprehensive authentication system with 2FA support
- Add new auth module with captcha, login, logout, register, and email verification endpoints
- Implement two-factor authentication with TOTP enable, disable, verify, and backup codes regeneration
- Create RSA public key endpoint for secure password encryption
- Add user profile management with get current user and email retrieval
- Integrate OpenAPI documentation for all authentication endpoints
- Implement password reset functionality with email verification flow
- Add comprehensive API response structures with proper error handling
- Configure all auth routes under /api/v1/auth scope with proper tagging
2026-06-07 18:09:38 +08:00
zhenyi 2bb5834167 feat(session): add session middleware with Redis storage support
- Implemented SessionMiddleware with cookie-based session key management
- Added support for encrypted and signed session cookies
- Integrated Redis backend for session state persistence
- Implemented session lifecycle management (create, update, delete)
- Added TTL extension policies for session timeout handling
- Created fluent builder for session middleware configuration
- Implemented cookie security features (secure, http-only, same-site)
- Added session state loading and persistence logic
- Implemented proper error handling and logging for session operations
- Added support for configurable session cookie parameters
- Implemented session key extraction and validation from requests
2026-06-07 17:42:27 +08:00
zhenyi 4e2c1c932a feat(session): integrate actix-web framework with enhanced session management
- Added actix-web and actix-multipart dependencies to Cargo.toml
- Integrated actix-web ResponseError trait for AppError handling
- Migrated session module to use actix-web request lifecycle management
- Enhanced Session struct with request-local state handling capabilities
- Implemented proper HTTP status code mapping for various error types
- Added comprehensive session middleware integration points
- Updated session state persistence and modification tracking logic
- Integrated proper JSON response formatting for error messages
- Added support for session renewal, purge, and unchanged state management
2026-06-07 17:41:57 +08:00
zhenyi 6a8e978073 feat: init 2026-06-07 11:31:00 +08:00
zhenyi 563381c1ca feat: init 2026-06-07 11:30:56 +08:00
878 changed files with 121922 additions and 43749 deletions
View File
+1
View File
@@ -1,4 +1,5 @@
.codegraph .codegraph
.claude
target target
.git .git
.idea .idea
+120 -23
View File
@@ -1,23 +1,120 @@
REPO_PREFIX_PATH=/home/zhenyi/RustroverProjects/gitks/data # HTTP Server
GITKS_HOST=0.0.0.0 APP_HTTP_HOST=0.0.0.0
GITKS_PORT=50051 APP_HTTP_PORT=8000
GITKS_ADVERTISE_ADDR=http://gitks-node1:50051 APP_HTTP_WORKERS=4
GITKS_METRICS_PORT=9100 APP_HTTP_JSON_LIMIT_BYTES=10485760
GITKS_DISK_CACHE_ENABLED=false
GITKS_DISK_CACHE_MAX_AGE=300 # App
GITKS_PACK_CACHE_ENABLED=true APP_URL=http://localhost:8000
GITKS_PACK_CACHE_BACKPRESSURE=true APP_MAIN_DOMAIN=localhost
GITKS_RATE_LIMIT_MAX_CONCURRENT=100
GITKS_HOOKS_ENABLED=true # Session
GITKS_HOOK_TIMEOUT=30 APP_SESSION_SECRET=change-me-to-a-secure-random-string-at-least-32-bytes
GITKS_ALLOW_CUSTOM_HOOKS=true APP_SESSION_COOKIE_NAME=sid
#GITKS_SERVER_HOOKS_DIR=/etc/gitks/hooks APP_SESSION_COOKIE_SECURE=false
GITKS_HOOK_CALLBACK_ADDR=http://localhost:50052 APP_SESSION_COOKIE_HTTP_ONLY=true
GITKS_ETCD_ENDPOINTS=http://localhost:2379 APP_SESSION_COOKIE_SAME_SITE=Lax
GITKS_CLUSTER_PORT=4697 APP_SESSION_COOKIE_PATH=/
GITKS_CLUSTER_COOKIE=gitks-default-cookie APP_SESSION_COOKIE_DOMAIN=
GITKS_LEASE_TTL=15 APP_SESSION_TTL_SECS=86400
GITKS_ETCD_CONNECT_TIMEOUT=5000 APP_SESSION_MAX_AGE_SECS=86400
GITKS_HEALTH_CHECK_INTERVAL=1
GITKS_MAX_HEALTH_FAILURES=10 # PostgreSQL
STORAGE_NAME=default DATABASE_URL=postgres://appks:appks@localhost:5432/appks
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
View File
@@ -1,8 +1,8 @@
/target /target
.idea .idea
.codegraph .codegraph
.classpath .claude
.project .env*
.settings !.env.example
.DS_Store AGENT.md
.env CLAUDE.md
+10
View File
@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+580 -87
View File
@@ -1,121 +1,469 @@
# AGENTS.md — Development Guidelines # AGENTS.md — 开发规范 / Development Guidelines
> Unified development guidelines for all AI coding assistants (Claude Code, Cursor, etc.) > 本文件为所有 AI 编码助手(Claude Code、pi、Cursor 等)提供统一的开发指导。
> This file provides unified development guidelines for all AI coding assistants.
**Last Updated**: 2026-06-11 **最后更新 / Last Updated**: 2026-06-10
--- ---
## 1. Language ## 目录 / Table of Contents
Always respond in **Chinese (中文)**. Code, commands, and technical terms remain in English. 1. [语言 / Language](#1-语言--language)
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)
--- ---
## 2. Code Style ## 1. 语言 / Language
### 2.1 Basic Principles **Always respond in Chinese (中文).** Use the user's language for all conversations and explanations. Code, commands, and technical terms can remain in English.
- 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 ---
- Use `?` operator; never use `unwrap()` in non-test code ## 2. 代码风格 / Code Style
- 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.3 Import Order ### 2.1 基本原则 / Basic Principles
| 规则 / 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
// std → third-party crates → local modules // ✅ 正确 / Correct
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
- `panic!()` / `unreachable!()` — use error types instead The following code patterns are strictly forbidden in this project:
- Untracked `todo!()` — must have a corresponding issue
- Commented-out code — use Git history instead | 禁止项 / Forbidden | 说明 / Reason |
- Nesting depth ≥ 4 — flatten with early return |-------------------------------|------------------------------------------------|
- Functions > 50 lines — split into smaller functions | `// ── xxxx ──────────` | 禁止使用此类分隔线注释;使用 `// Section: xxx` 格式替代 |
- Magic numbers — define named `const` | `unwrap()` / `expect()` (非测试) | 在非测试代码中禁止使用;使用 `?``unwrap_or` 等安全替代 |
- Hardcoded strings — use enums or constants | `panic!()` / `unreachable!()` | 除极少数不可能到达的分支外禁止使用;使用 `AppError` 替代 |
| 未处理的 `todo!()` | 不得提交包含 `todo!()` 的代码,除非有对应的 issue 追踪 |
| 注释掉的代码 | 不得提交被注释的代码块;使用 Git 历史追溯 |
| 过深嵌套 (≥4层) | 使用 early return、`match``map`/`and_then` 扁平化逻辑 |
| 过长函数 (>50行) | 拆分为更小的、职责单一的函数 |
| 魔法数字 | 使用 `const` 定义命名常量 |
| 硬编码字符串 | 使用枚举或常量定义配置值/状态值 |
| 死代码 | 删除未使用的代码、导入和变量 |
--- ---
## 4. Error Handling ## 4. 错误处理 / Error Handling
### 4.1 Principles ### 4.1 错误类型体系 / Error Type System
- 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,
operation = "operation_name", user_id = %user_id,
"Failed to perform operation" operation = "create_user",
"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"
); );
``` ```
--- ---
## 5. Security ## 9. 性能规范 / Performance
- Never hardcode secrets or API keys ### 9.1 SLA 目标 / SLA Targets
- Always validate and sanitize user input
- Use parameterized queries (no SQL injection) | 指标 / Metric | 目标 / Target |
- 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 |
--- ---
## 6. Workflow ## 10. 测试规范 / Testing
### 6.1 Development Flow ### 10.1 基础要求 / Basic Requirements
1. **Read before write** — understand context first | 规则 / Rule | 说明 / Description |
2. **Minimal changes** — don't refactor unrelated code |---|---|
3. **Verify after changes** — run tests or check output | 新功能 | All new features must have unit tests |
| Bug 修复 | Bug fixes must include regression tests |
| 关键路径 | Critical business logic must have integration tests |
| 测试隔离 | Tests must be independent and not depend on execution order |
### 6.2 AI Assistant Rules ### 10.2 测试命令 / Test Commands
- 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 build # Build cargo test # 运行所有测试 / Run all tests
cargo check # Quick syntax check cargo test -- <test_name> # 按名称运行 / Run by name
cargo test # Run tests cargo test lru::tests # 运行特定模块 / Run module tests
cargo clippy # Lint cargo test -- --nocapture # 显示输出 / Show output
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() { ... }
}
``` ```
--- ---
## 7. Git Workflow ## 11. Git 规范 / Git Workflow
### 7.1 Commit Message Format ### 11.1 提交信息格式 / Commit Message Format
使用 Angular 风格,全部英文:
Use Angular style, all English:
``` ```
<type>(<scope>): <subject> <type>(<scope>): <subject>
@@ -125,37 +473,182 @@ cargo fmt # Format
[optional footer] [optional footer]
``` ```
Types: `feat` · `fix` · `refactor` · `docs` · `test` · `chore` | Type | 说明 / Description |
|---|---|
| `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 |
### 7.2 Commit Principles **示例 / Examples:**
```
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
```
- Each commit addresses one concern (atomic) ### 11.2 提交原则 / Commit Principles
- Each commit leaves the codebase in a working state
- Never force push to `main` | 原则 / Principle | 说明 / Description |
|---|---|
| 原子提交 | 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 |
--- ---
## Appendix: Architecture Overview ## 12. 工作流程 / Workflow
``` ### 12.1 开发流程 / Development Process
gitks — Git Repository Operations Service
actor/ → Actor model 1. **理解先于编写** — Read before write; understand context first
archive/ → Archive operations 2. **最小变更** — Minimal changes; don't refactor unrelated code
blame/ → Blame operations 3. **验证变更** — Verify after changes; run tests or check output
blob/ → Blob objects 4. **文档同步** — Update documentation when changing public APIs
branch/ → Branch operations
commit/ → Commit operations ### 12.2 AI 助手工作规范 / AI Assistant Guidelines
diff/ → Diff operations
merge/ → Merge operations | 规则 / Rule | 说明 / Description |
pack/ → Pack operations |---|---|
refs/ → Reference management | 先读后写 | Always read existing code before making changes |
remote/ → Remote operations | 最小侵入 | Make minimal changes; don't refactor unrelated code |
repository/ → Repository operations | 验证结果 | Run `cargo check` or `cargo test` after changes |
server/ → gRPC server | 解释变更 | Explain what you changed and why |
| 询问不确定 | 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
``` ```
--- ---
*For questions or suggestions, please open an issue.* ## 13. 架构决策记录 / ADR
架构决策记录存放在 `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.*
-96
View File
@@ -1,96 +0,0 @@
# 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
+3591 -1164
View File
File diff suppressed because it is too large Load Diff
+47 -39
View File
@@ -1,53 +1,61 @@
[package] [package]
name = "gitks" name = "appks"
version = "1.0.0" version = "0.1.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]
moka = { version = "0.12", default-features = false, features = ["sync"] } sqlx = { version = "0.9", features = ["postgres","runtime-tokio","chrono","uuid","json","migrate"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = { version = "1", features = [] }
sha2 = "0.11" chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v7"] } uuid = { version = "1", features = ["serde","v4","v7","v5"] }
gix = { version = "0.84", default-features = false, features = ["serde", "blame", "sha256", "sha1", "tracing", "merge", "max-performance-safe", "revision"] } reqwest = { version = "0.13", features = ["json"] }
gix-archive = { version = "0.33", features = ["sha256","sha1","document-features"] } tracing = { version = "0.1", features = [] }
duct = { version = "1", features = [] } tracing-subscriber = { version = "0.3", features = ["fmt"] }
tracing = { version = "0.1", features = ["log"] } dotenvy = "0.15"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } thiserror = "2"
tracing-appender = "0.2" redis = { version = "1", features = ["cluster","cluster-async","aio","tokio-comp","connection-manager"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "sync", "net", "signal"] } dashmap = "6"
tokio-stream = { version = "0.1", features = ["full"] } object_store = { version = "0.13", features = ["tokio","aws","cloud"] }
tokio-util = "0.7" argon2 = "0.5"
thiserror = { version = "2", features = [] } rsa = "0.9"
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"
tempfile = "3" tonic-health = "0.14.6"
dotenvy = "0.15" url = "2.5"
etcd-client = { version = "0.18", features = ["tls"] } etcd-client = { version = "0.18", features = ["tls"] }
dashmap = "6" tokio-stream = { version = "0.1", features = ["net"] }
hyper = { version = "1", features = ["server", "http1"] } async-nats = "0.49"
hyper-util = { version = "0.1", features = ["tokio"] } futures-util = "0.3"
http-body-util = "0.1" utoipa = { version = "5", features = ["uuid","chrono","actix_extras","decimal","macros"]}
bytes = "1" actix-web = { version = "4", features = ["secure-cookies"] }
crc32fast = "1" actix-multipart = "0.7"
[[bin]] hex = "0.4"
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
View File
@@ -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 gitks && \ RUN cargo build --release --bin appks && \
strip target/release/gitks strip target/release/appks
FROM ubuntu:26.04 FROM ubuntu:26.04
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends git ca-certificates && \ apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/gitks /usr/local/bin/gitks COPY --from=builder /app/target/release/appks /usr/local/bin/appks
ENV GITKS_HOST=0.0.0.0 ENV APP_HTTP_HOST=0.0.0.0
ENV GITKS_PORT=50051 ENV APP_HTTP_PORT=8000
ENV REPO_PREFIX_PATH=/data/repos ENV APP_RPC_SELF_HOST=0.0.0.0
ENV APP_RPC_SELF_PORT=50049
RUN mkdir -p /data/repos EXPOSE 8000 50049
EXPOSE 50051 ENTRYPOINT ["appks"]
ENTRYPOINT ["gitks"]
+8 -9
View File
@@ -1,17 +1,16 @@
FROM ubuntu:26.04 FROM ubuntu:26.04
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends git && \ apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY target/release/gitks /usr/local/bin/gitks COPY target/release/appks /usr/local/bin/appks
ENV GITKS_HOST=0.0.0.0 ENV APP_HTTP_HOST=0.0.0.0
ENV GITKS_PORT=50051 ENV APP_HTTP_PORT=8000
ENV REPO_PREFIX_PATH=/data/repos ENV APP_RPC_SELF_HOST=0.0.0.0
ENV APP_RPC_SELF_PORT=50049
RUN mkdir -p /data/repos EXPOSE 8000 50049
EXPOSE 50051 ENTRYPOINT ["appks"]
ENTRYPOINT ["gitks"]
-58
View File
@@ -1,58 +0,0 @@
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
-7
View File
@@ -1,7 +0,0 @@
# 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.
+38
View File
@@ -0,0 +1,38 @@
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)))
}
+33
View File
@@ -0,0 +1,33 @@
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")))
}
+38
View File
@@ -0,0 +1,38 @@
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")))
}
+29
View File
@@ -0,0 +1,29 @@
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)))
}
+28
View File
@@ -0,0 +1,28 @@
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)))
}
+28
View File
@@ -0,0 +1,28 @@
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)))
}
+38
View File
@@ -0,0 +1,38 @@
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")))
}
+26
View File
@@ -0,0 +1,26 @@
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")))
}
+29
View File
@@ -0,0 +1,29 @@
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)))
}
+64
View File
@@ -0,0 +1,64 @@
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;
pub mod ws_token;
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("/ws-token", web::post().to(ws_token::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),
),
);
}
+54
View File
@@ -0,0 +1,54 @@
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,
})),
)
}
+64
View File
@@ -0,0 +1,64 @@
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))))
}
+38
View File
@@ -0,0 +1,38 @@
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)))
}
+39
View File
@@ -0,0 +1,39 @@
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")))
}
+34
View File
@@ -0,0 +1,34 @@
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")))
}
+27
View File
@@ -0,0 +1,27 @@
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)))
}
+38
View File
@@ -0,0 +1,38 @@
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")))
}
+34
View File
@@ -0,0 +1,34 @@
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")))
}
+37
View File
@@ -0,0 +1,37 @@
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")))
}
+57
View File
@@ -0,0 +1,57 @@
use std::collections::HashMap;
use actix_web::{HttpResponse, web};
use serde::Serialize;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
/// Response payload for `POST /auth/ws-token`.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct WsTokenResponse {
/// Short-lived JWT prefixed with "Bearer " for use in the Socket.IO CONNECT auth packet.
pub token: String,
/// Unix timestamp (seconds) when the token expires.
pub expires_at: i64,
}
#[utoipa::path(
post,
path = "/api/v1/auth/ws-token",
tag = "Auth",
operation_id = "authWsToken",
summary = "Issue a short-lived WebSocket token",
description = "Issue a short-lived JWT (30 minutes) scoped to IM WebSocket access. \
The token is signed by the appks signing key and can be verified by imks either \
locally (via cached signing keys) or via RPC. The returned token should be passed \
as `{ token: <value> }` in the Socket.IO CONNECT auth packet. Requires an \
authenticated session.",
responses(
(status = 200, description = "Token issued successfully.", body = ApiResponse<WsTokenResponse>),
(status = 401, description = "The current session is unauthenticated or the login state has expired.", body = ApiErrorResponse),
(status = 500, description = "Token issuance or Redis write failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let user_uid = session.user().ok_or(AppError::Unauthorized)?;
let issued = service
.internal_auth
.issue_token(
&user_uid.to_string(),
1800, // 30-minute TTL (frontend refreshes every 25 min)
vec!["im:read".into(), "im:write".into()],
HashMap::new(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(WsTokenResponse {
token: format!("Bearer {}", issued.access_token),
expires_at: issued.expires_at,
})))
}
+51
View File
@@ -0,0 +1,51 @@
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)))
}
+44
View File
@@ -0,0 +1,44 @@
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")))
}
+44
View File
@@ -0,0 +1,44 @@
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)))
}
+58
View File
@@ -0,0 +1,58 @@
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)))
}
+65
View File
@@ -0,0 +1,65 @@
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)))
}
+50
View File
@@ -0,0 +1,50 @@
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")))
}
+52
View File
@@ -0,0 +1,52 @@
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)))
}
+82
View File
@@ -0,0 +1,82 @@
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)))
}
+67
View File
@@ -0,0 +1,67 @@
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)))
}
+58
View File
@@ -0,0 +1,58 @@
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)))
}
+45
View File
@@ -0,0 +1,45 @@
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)))
}
+50
View File
@@ -0,0 +1,50 @@
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")))
}
+44
View File
@@ -0,0 +1,44 @@
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")))
}
+58
View File
@@ -0,0 +1,58 @@
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)))
}
+60
View File
@@ -0,0 +1,60 @@
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)))
}
+77
View File
@@ -0,0 +1,77 @@
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),
),
);
}
+65
View File
@@ -0,0 +1,65 @@
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,
})))
}
+10
View File
@@ -0,0 +1,10 @@
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)),
);
}
+60
View File
@@ -0,0 +1,60 @@
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)))
}
+59
View File
@@ -0,0 +1,59 @@
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)))
}
+56
View File
@@ -0,0 +1,56 @@
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)))
}
+82
View File
@@ -0,0 +1,82 @@
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))))
}
+73
View File
@@ -0,0 +1,73 @@
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))))
}
+62
View File
@@ -0,0 +1,62 @@
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)))
}
+70
View File
@@ -0,0 +1,70 @@
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)))
}
+53
View File
@@ -0,0 +1,53 @@
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())))
}
+52
View File
@@ -0,0 +1,52 @@
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())))
}
+57
View File
@@ -0,0 +1,57 @@
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())))
}
+62
View File
@@ -0,0 +1,62 @@
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())))
}
+57
View File
@@ -0,0 +1,57 @@
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))))
}
+98
View File
@@ -0,0 +1,98 @@
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)))
}
+66
View File
@@ -0,0 +1,66 @@
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)))
}
+72
View File
@@ -0,0 +1,72 @@
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)))
}
+67
View File
@@ -0,0 +1,67 @@
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)))
}
+66
View File
@@ -0,0 +1,66 @@
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)))
}
+46
View File
@@ -0,0 +1,46 @@
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)))
}
+66
View File
@@ -0,0 +1,66 @@
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)))
}
+62
View File
@@ -0,0 +1,62 @@
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)))
}
+185
View File
@@ -0,0 +1,185 @@
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),
),
);
}
+170
View File
@@ -0,0 +1,170 @@
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())))
}
+169
View File
@@ -0,0 +1,169 @@
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())))
}
+51
View File
@@ -0,0 +1,51 @@
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)))
}
+169
View File
@@ -0,0 +1,169 @@
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())))
}
+186
View File
@@ -0,0 +1,186 @@
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())))
}
+220
View File
@@ -0,0 +1,220 @@
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())))
}
+75
View File
@@ -0,0 +1,75 @@
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,
&params.target_workspace_name,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
+57
View File
@@ -0,0 +1,57 @@
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())))
}
+57
View File
@@ -0,0 +1,57 @@
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())))
}
+66
View File
@@ -0,0 +1,66 @@
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)))
}
+59
View File
@@ -0,0 +1,59 @@
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)))
}
+59
View File
@@ -0,0 +1,59 @@
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)))
}
+75
View File
@@ -0,0 +1,75 @@
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
View File
@@ -0,0 +1,13 @@
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;
+29
View File
@@ -0,0 +1,29 @@
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)))
}
+40
View File
@@ -0,0 +1,40 @@
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)))
}
+40
View File
@@ -0,0 +1,40 @@
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)))
}
+41
View File
@@ -0,0 +1,41 @@
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)))
}
+39
View File
@@ -0,0 +1,39 @@
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())))
}
+42
View File
@@ -0,0 +1,42 @@
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())))
}
+42
View File
@@ -0,0 +1,42 @@
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())))
}
+43
View File
@@ -0,0 +1,43 @@
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())))
}
+66
View File
@@ -0,0 +1,66 @@
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),
)))
}
+44
View File
@@ -0,0 +1,44 @@
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)))
}
+29
View File
@@ -0,0 +1,29 @@
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)))
}
+47
View File
@@ -0,0 +1,47 @@
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)))
}
+47
View File
@@ -0,0 +1,47 @@
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)))
}
@@ -0,0 +1,55 @@
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)))
}

Some files were not shown because too many files have changed in this diff Show More