diff --git a/apps/backend/crates/handler/src/handlers/task_extensions.rs b/apps/backend/crates/handler/src/handlers/task_extensions.rs index c48f1f890..7b613f132 100644 --- a/apps/backend/crates/handler/src/handlers/task_extensions.rs +++ b/apps/backend/crates/handler/src/handlers/task_extensions.rs @@ -29,6 +29,7 @@ use service::db::is_postgres_unique_violation; use service::task_activities::{ record_activity, record_label_diff, status_name, task_label_entries, }; +use std::collections::HashSet; const BULK_MAX_TASKS: usize = 100; @@ -295,6 +296,15 @@ pub async fn bulk_update_tasks( if payload.task_ids.len() > BULK_MAX_TASKS { return Err(AppError::BadRequest); } + if let (Some(add), Some(remove)) = ( + &payload.update.add_label_ids, + &payload.update.remove_label_ids, + ) { + let remove: HashSet<&Uuid> = remove.iter().collect(); + if add.iter().any(|id| remove.contains(id)) { + return Err(AppError::BadRequest); + } + } let mut unique_ids = payload.task_ids.clone(); unique_ids.sort(); @@ -431,9 +441,9 @@ async fn apply_bulk_update( } // ラベル変更の前後スナップショット。実際に集合が変わったときだけ記録する。 - // remove_label_ids などラベルを変える入力が増えたら、この判定式に足すこと。 + // ラベルを変える入力が増えたら、この判定式に足すこと。 // ここを忘れると記録だけが静かに欠ける - let labels_will_change = update.add_label_ids.is_some(); + let labels_will_change = update.add_label_ids.is_some() || update.remove_label_ids.is_some(); let before_labels = if labels_will_change { Some(task_label_entries(&txn, task_id).await?) } else { @@ -472,6 +482,19 @@ async fn apply_bulk_update( } } + if let Some(ref label_ids) = update.remove_label_ids { + let mut unique = label_ids.clone(); + unique.sort(); + unique.dedup(); + if !unique.is_empty() { + task_labels::Entity::delete_many() + .filter(task_labels::Column::TaskId.eq(task_id)) + .filter(task_labels::Column::LabelId.is_in(unique)) + .exec(&txn) + .await?; + } + } + if let Some(before_labels) = before_labels { let after_labels = task_label_entries(&txn, task_id).await?; record_label_diff(&txn, task_id, Some(user_id), &before_labels, &after_labels).await?; diff --git a/apps/backend/crates/payload/src/task_extensions.rs b/apps/backend/crates/payload/src/task_extensions.rs index abe73662a..a1789084f 100644 --- a/apps/backend/crates/payload/src/task_extensions.rs +++ b/apps/backend/crates/payload/src/task_extensions.rs @@ -1,175 +1,178 @@ -use chrono::{DateTime, Utc}; -use sea_orm::prelude::Uuid; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use utoipa::ToSchema; -use validator::Validate; - -use entity::project_task_views; - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct ProjectTaskViewResponse { - #[schema(value_type = String, format = "uuid")] - pub id: Uuid, - #[schema(value_type = String, format = "uuid")] - pub project_id: Uuid, - #[schema(value_type = String, format = "uuid")] - pub created_by: Uuid, - pub name: String, - pub is_shared: bool, - pub filters: Value, - pub sort: Value, - pub view_type: String, - #[schema(value_type = String, format = "date-time")] - pub created_at: DateTime, - #[schema(value_type = String, format = "date-time")] - pub updated_at: DateTime, -} - -impl From for ProjectTaskViewResponse { - fn from(model: project_task_views::Model) -> Self { - Self { - id: model.id, - project_id: model.project_id, - created_by: model.created_by, - name: model.name, - is_shared: model.is_shared, - filters: model.filters, - sort: model.sort, - view_type: model.view_type, - created_at: model.created_at.with_timezone(&Utc), - updated_at: model.updated_at.with_timezone(&Utc), - } - } -} - -#[derive(Deserialize, ToSchema, utoipa::IntoParams)] -#[into_params(parameter_in = Query)] -pub struct SearchTasksQuery { - pub q: String, - #[serde(default = "default_search_limit")] - pub limit: u64, - #[serde(default)] - pub offset: u64, -} - -fn default_search_limit() -> u64 { - 20 -} - -#[derive(Serialize, ToSchema)] -pub struct SearchTaskHit { - #[schema(value_type = String, format = "uuid")] - pub id: Uuid, - pub seq_id: i32, - pub title: String, - pub highlight: String, - pub score: f32, -} - -#[derive(Serialize, ToSchema)] -pub struct SearchTasksResponse { - pub tasks: Vec, - pub total: u64, -} - -#[derive(Deserialize, ToSchema)] -pub struct BulkUpdateFields { - #[schema(value_type = Option, format = "uuid")] - pub status_id: Option, - #[schema(value_type = Option, format = "uuid")] - pub assignee_id: Option, - /// 既存ラベルに追加する ID 一覧(上書きではない)。 - pub add_label_ids: Option>, - #[schema(value_type = Option, format = "uuid")] - pub sprint_id: Option, - #[serde(default)] - pub clear_sprint_id: bool, -} - -#[derive(Validate, Deserialize, ToSchema)] -pub struct BulkUpdateRequest { - #[validate(length(min = 1))] - pub task_ids: Vec, - pub update: BulkUpdateFields, -} - -#[derive(Serialize, ToSchema)] -pub struct BulkUpdateResponse { - pub updated: u32, - pub failed: Vec, -} - -#[derive(Serialize, ToSchema)] -pub struct BulkFailure { - #[schema(value_type = String, format = "uuid")] - pub task_id: Uuid, - pub reason: String, -} - -#[derive(Serialize, ToSchema)] -pub struct TaskViewListResponse { - pub views: Vec, -} - -#[derive(Validate, Deserialize, ToSchema)] -pub struct CreateTaskViewRequest { - #[validate(length(min = 1, max = 100))] - pub name: String, - #[serde(default)] - pub is_shared: bool, - #[serde(default)] - pub filters: serde_json::Value, - #[serde(default)] - pub sort: serde_json::Value, - #[serde(default = "default_view_type")] - #[validate(custom(function = "validate_view_type"))] - pub view_type: String, -} - -fn default_view_type() -> String { - "list".into() -} - -fn validate_view_type(view_type: &str) -> Result<(), validator::ValidationError> { - match view_type { - "board" | "list" | "table" => Ok(()), - _ => Err(validator::ValidationError::new("view_type")), - } -} - -#[derive(Validate, Deserialize, ToSchema)] -pub struct UpdateTaskViewRequest { - #[validate(length(min = 1, max = 100))] - pub name: Option, - pub is_shared: Option, - pub filters: Option, - pub sort: Option, - #[validate(custom(function = "validate_view_type"))] - pub view_type: Option, -} - -#[derive(Serialize, ToSchema)] -pub struct TaskAttachmentResponse { - #[schema(value_type = String, format = "uuid")] - pub id: Uuid, - #[schema(value_type = String, format = "uuid")] - pub drive_file_id: Uuid, - pub name: String, - pub mime_type: String, - pub size: i64, - pub url: String, - #[schema(value_type = String, format = "date-time")] - pub created_at: chrono::DateTime, -} - -#[derive(Serialize, ToSchema)] -pub struct TaskAttachmentListResponse { - pub attachments: Vec, -} - -#[derive(Validate, Deserialize, ToSchema)] -pub struct AttachFileRequest { - #[schema(value_type = String, format = "uuid")] - pub drive_file_id: Uuid, -} +use chrono::{DateTime, Utc}; +use sea_orm::prelude::Uuid; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use utoipa::ToSchema; +use validator::Validate; + +use entity::project_task_views; + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ProjectTaskViewResponse { + #[schema(value_type = String, format = "uuid")] + pub id: Uuid, + #[schema(value_type = String, format = "uuid")] + pub project_id: Uuid, + #[schema(value_type = String, format = "uuid")] + pub created_by: Uuid, + pub name: String, + pub is_shared: bool, + pub filters: Value, + pub sort: Value, + pub view_type: String, + #[schema(value_type = String, format = "date-time")] + pub created_at: DateTime, + #[schema(value_type = String, format = "date-time")] + pub updated_at: DateTime, +} + +impl From for ProjectTaskViewResponse { + fn from(model: project_task_views::Model) -> Self { + Self { + id: model.id, + project_id: model.project_id, + created_by: model.created_by, + name: model.name, + is_shared: model.is_shared, + filters: model.filters, + sort: model.sort, + view_type: model.view_type, + created_at: model.created_at.with_timezone(&Utc), + updated_at: model.updated_at.with_timezone(&Utc), + } + } +} + +#[derive(Deserialize, ToSchema, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] +pub struct SearchTasksQuery { + pub q: String, + #[serde(default = "default_search_limit")] + pub limit: u64, + #[serde(default)] + pub offset: u64, +} + +fn default_search_limit() -> u64 { + 20 +} + +#[derive(Serialize, ToSchema)] +pub struct SearchTaskHit { + #[schema(value_type = String, format = "uuid")] + pub id: Uuid, + pub seq_id: i32, + pub title: String, + pub highlight: String, + pub score: f32, +} + +#[derive(Serialize, ToSchema)] +pub struct SearchTasksResponse { + pub tasks: Vec, + pub total: u64, +} + +#[derive(Deserialize, ToSchema)] +pub struct BulkUpdateFields { + #[schema(value_type = Option, format = "uuid")] + pub status_id: Option, + #[schema(value_type = Option, format = "uuid")] + pub assignee_id: Option, + /// 既存ラベルに追加する ID 一覧(上書きではない)。 + pub add_label_ids: Option>, + /// 既存ラベルから外す ID 一覧。未付与・プロジェクト外の ID は単に無視される。 + /// add_label_ids と同じ ID を含む場合は 400 + pub remove_label_ids: Option>, + #[schema(value_type = Option, format = "uuid")] + pub sprint_id: Option, + #[serde(default)] + pub clear_sprint_id: bool, +} + +#[derive(Validate, Deserialize, ToSchema)] +pub struct BulkUpdateRequest { + #[validate(length(min = 1))] + pub task_ids: Vec, + pub update: BulkUpdateFields, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkUpdateResponse { + pub updated: u32, + pub failed: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkFailure { + #[schema(value_type = String, format = "uuid")] + pub task_id: Uuid, + pub reason: String, +} + +#[derive(Serialize, ToSchema)] +pub struct TaskViewListResponse { + pub views: Vec, +} + +#[derive(Validate, Deserialize, ToSchema)] +pub struct CreateTaskViewRequest { + #[validate(length(min = 1, max = 100))] + pub name: String, + #[serde(default)] + pub is_shared: bool, + #[serde(default)] + pub filters: serde_json::Value, + #[serde(default)] + pub sort: serde_json::Value, + #[serde(default = "default_view_type")] + #[validate(custom(function = "validate_view_type"))] + pub view_type: String, +} + +fn default_view_type() -> String { + "list".into() +} + +fn validate_view_type(view_type: &str) -> Result<(), validator::ValidationError> { + match view_type { + "board" | "list" | "table" => Ok(()), + _ => Err(validator::ValidationError::new("view_type")), + } +} + +#[derive(Validate, Deserialize, ToSchema)] +pub struct UpdateTaskViewRequest { + #[validate(length(min = 1, max = 100))] + pub name: Option, + pub is_shared: Option, + pub filters: Option, + pub sort: Option, + #[validate(custom(function = "validate_view_type"))] + pub view_type: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct TaskAttachmentResponse { + #[schema(value_type = String, format = "uuid")] + pub id: Uuid, + #[schema(value_type = String, format = "uuid")] + pub drive_file_id: Uuid, + pub name: String, + pub mime_type: String, + pub size: i64, + pub url: String, + #[schema(value_type = String, format = "date-time")] + pub created_at: chrono::DateTime, +} + +#[derive(Serialize, ToSchema)] +pub struct TaskAttachmentListResponse { + pub attachments: Vec, +} + +#[derive(Validate, Deserialize, ToSchema)] +pub struct AttachFileRequest { + #[schema(value_type = String, format = "uuid")] + pub drive_file_id: Uuid, +} diff --git a/apps/backend/tests/task_labels_integration.rs b/apps/backend/tests/task_labels_integration.rs index bfe20dcc8..53f82a50c 100644 --- a/apps/backend/tests/task_labels_integration.rs +++ b/apps/backend/tests/task_labels_integration.rs @@ -339,6 +339,137 @@ async fn task_labels_suite() { .count(); // 内訳: label_added 4 件(feature / bug / feature / docs)+ label_removed 2 件(bug / feature) assert_eq!(bulk_noop_count, 6); + + // 一括更新の remove_label_ids でラベルを外せる(label_removed として記録される) + let bulk_remove = app + .post_json_with_session( + &bulk_path, + serde_json::json!({ + "task_ids": [task_uuid], + "update": { "remove_label_ids": [label_ids[2]] } + }), + ) + .await; + assert_eq!(bulk_remove.status(), StatusCode::OK); + let after_remove = app.get_with_session(&task_path).await; + let after_remove_body: Value = after_remove.json().await.expect("after remove json"); + let names: Vec<&str> = after_remove_body["labels"] + .as_array() + .expect("labels") + .iter() + .map(|l| l["name"].as_str().expect("name")) + .collect(); + assert_eq!(names, ["bug", "feature"]); + let remove_activities = app.get_with_session(&activities_path).await; + let remove_body: Value = remove_activities.json().await.expect("remove json"); + let removed_events: Vec<&Value> = remove_body["activities"] + .as_array() + .expect("activities array") + .iter() + .filter(|a| a["event_type"] == "label_removed") + .collect(); + // 内訳: 置き換えで bug と feature + 今回の bulk remove で docs + assert_eq!(removed_events.len(), 3); + let docs_removed = removed_events + .iter() + .find(|a| a["payload"]["name"] == "docs") + .expect("bulk label_removed event"); + assert_eq!(docs_removed["payload"]["label_id"], label_ids[2]); + + // add と remove に同じ ID を含む一括更新は 400 + let conflicting = app + .post_json_with_session( + &bulk_path, + serde_json::json!({ + "task_ids": [task_uuid], + "update": { + "add_label_ids": [label_ids[0]], + "remove_label_ids": [label_ids[0]] + } + }), + ) + .await; + assert_eq!(conflicting.status(), StatusCode::BAD_REQUEST); + + // 未付与・他プロジェクトのラベル ID の remove は 200 の no-op(記録もされない) + let harmless = app + .post_json_with_session( + &bulk_path, + serde_json::json!({ + "task_ids": [task_uuid], + "update": { "remove_label_ids": [foreign_id] } + }), + ) + .await; + assert_eq!(harmless.status(), StatusCode::OK); + let harmless_body: Value = harmless.json().await.expect("harmless json"); + assert_eq!(harmless_body["updated"], 1); + let final_task = app.get_with_session(&task_path).await; + let final_body: Value = final_task.json().await.expect("final json"); + assert_eq!(final_body["labels"].as_array().expect("labels").len(), 2); + let final_activities = app.get_with_session(&activities_path).await; + let final_activities_body: Value = final_activities.json().await.expect("final acts json"); + let final_count = final_activities_body["activities"] + .as_array() + .expect("activities array") + .iter() + .filter(|a| a["event_type"] == "label_added" || a["event_type"] == "label_removed") + .count(); + // no-op なので直前から増えない(label_added 4 件 + label_removed 3 件) + assert_eq!(final_count, 7); + + // 互いに異なる ID なら add と remove を 1 リクエストで同時適用できる + // (未付与の docs を add、付与済みの bug を remove → [docs, feature]) + let bulk_both = app + .post_json_with_session( + &bulk_path, + serde_json::json!({ + "task_ids": [task_uuid], + "update": { + "add_label_ids": [label_ids[2]], + "remove_label_ids": [label_ids[0]] + } + }), + ) + .await; + assert_eq!(bulk_both.status(), StatusCode::OK); + let after_both = app.get_with_session(&task_path).await; + let after_both_body: Value = after_both.json().await.expect("after both json"); + let both_names: Vec<&str> = after_both_body["labels"] + .as_array() + .expect("labels") + .iter() + .map(|l| l["name"].as_str().expect("name")) + .collect(); + assert_eq!(both_names, ["docs", "feature"]); + let both_activities = app.get_with_session(&activities_path).await; + let both_body: Value = both_activities.json().await.expect("both acts json"); + let both_added: Vec<&Value> = both_body["activities"] + .as_array() + .expect("activities array") + .iter() + .filter(|a| a["event_type"] == "label_added") + .collect(); + let both_removed: Vec<&Value> = both_body["activities"] + .as_array() + .expect("activities array") + .iter() + .filter(|a| a["event_type"] == "label_removed") + .collect(); + // 同時適用は add 側と remove 側を両方記録する(docs の追加 / bug の削除が 1 件ずつ増える) + assert_eq!(both_added.len(), 5); + assert_eq!(both_removed.len(), 4); + let docs_added = both_added + .iter() + .filter(|a| a["payload"]["name"] == "docs") + .count(); + let bug_removed = both_removed + .iter() + .filter(|a| a["payload"]["name"] == "bug") + .count(); + // docs は bulk 追加と今回の同時適用で 2 件、bug は最初の置き換えと今回で 2 件 + assert_eq!(docs_added, 2); + assert_eq!(bug_removed, 2); } /// 同じタスクのラベル集合を並行して置換しても、二つの集合が合流しない。 diff --git a/apps/cli/src/api/openapi-contract.ts b/apps/cli/src/api/openapi-contract.ts index 307661e3b..aaabdc63f 100644 --- a/apps/cli/src/api/openapi-contract.ts +++ b/apps/cli/src/api/openapi-contract.ts @@ -1,5 +1,5 @@ -import type { paths as GeneratedPaths } from "./openapi"; -import type { ApiPaths } from "./paths"; +import type { components, paths as GeneratedPaths } from "./openapi"; +import type { ApiPaths, BulkUpdateFields } from "./paths"; type HttpMethod = | "get" @@ -35,3 +35,14 @@ type AssertNoMissingOperations = Missing; // This type fails compilation when a path or HTTP method used by the CLI is // removed from the canonical OpenAPI contract. export type CliOpenApiContract = AssertNoMissingOperations; + +type AssertNoMissingFields = Missing; + +// This type fails compilation when the canonical OpenAPI schema gains a field +// that the hand-written CLI request type does not declare. +export type BulkUpdateFieldsContract = AssertNoMissingFields< + Exclude< + keyof components["schemas"]["BulkUpdateFields"], + keyof BulkUpdateFields + > +>; diff --git a/apps/cli/src/api/openapi.d.ts b/apps/cli/src/api/openapi.d.ts index 8a99282d7..010dc2b68 100644 --- a/apps/cli/src/api/openapi.d.ts +++ b/apps/cli/src/api/openapi.d.ts @@ -2115,6 +2115,11 @@ export interface components { /** Format: uuid */ assignee_id?: string | null; clear_sprint_id?: boolean; + /** + * @description 既存ラベルから外す ID 一覧。未付与・プロジェクト外の ID は単に無視される。 + * add_label_ids と同じ ID を含む場合は 400 + */ + remove_label_ids?: string[] | null; /** Format: uuid */ sprint_id?: string | null; /** Format: uuid */ diff --git a/apps/cli/src/api/paths.ts b/apps/cli/src/api/paths.ts index e3a2658d5..b182c569f 100644 --- a/apps/cli/src/api/paths.ts +++ b/apps/cli/src/api/paths.ts @@ -1,472 +1,473 @@ -export type User = { - id: string; - username: string; - email: string; -}; - -export type Tenant = { - id: string; - name: string; - display_id: string; -}; - -export type Project = { - id: string; - tenant_id: string; - name: string; - key: string; - description?: string | null; - is_personal?: boolean; -}; - -export type TaskPriority = - | "critical_fire" - | "critical" - | "high" - | "medium" - | "low" - | "trivial"; - -export type Task = { - id: string; - project_id: string; - seq_id: number; - title: string; - description?: string | null; - status_id: string; - priority: TaskPriority; - sprint_id?: string | null; - soft_deadline?: string | null; - hard_deadline?: string | null; - completed_at?: string | null; -}; - -export type TaskListResponse = { - tasks: Task[]; - total: number; -}; - -export type ProjectStatus = { - id: string; - project_id: string; - name: string; - color: string; - is_done_state: boolean; -}; - -export type Sprint = { - id: string; - project_id: string; - name: string; - goal?: string | null; - status: string; - start_date: string; - end_date: string; -}; - -export type SprintDetail = { - sprint: Sprint; - task_counts: { total: number; done: number; in_progress: number }; - burndown: Array<{ - date: string; - ideal_remaining: number; - actual_remaining: number; - }>; -}; - -export type MyTaskItem = { - id: string; - seq_id: number; - seq_key: string; - title: string; - project: { id: string; name: string; key: string; is_personal: boolean }; - status: { id: string; name: string; color: string }; -}; - -export type MyTasksListResponse = { - tasks: MyTaskItem[]; - total: number; -}; - -export type Comment = { - id: string; - task_id: string; - body: string; - user_id: string; - created_at: string; -}; - -export type SearchTaskHit = { - id: string; - seq_id: number; - title: string; - highlight: string; - score: number; -}; - -export type SearchTasksResponse = { - tasks: SearchTaskHit[]; - total: number; -}; - -export type BulkUpdateFields = { - status_id?: string; - assignee_id?: string; - add_label_ids?: string[]; - sprint_id?: string; - clear_sprint_id?: boolean; -}; - -export type BulkUpdateRequest = { - task_ids: string[]; - update: BulkUpdateFields; -}; - -export type BulkFailure = { - task_id: string; - reason: string; -}; - -export type BulkUpdateResponse = { - updated: number; - failed: BulkFailure[]; -}; - -export type TaskView = { - id: string; - project_id: string; - created_by: string; - name: string; - is_shared: boolean; - filters: Record; - sort: Record; - view_type: string; - created_at: string; -}; - -export type TaskViewListResponse = { - views: TaskView[]; -}; - -export type TaskAttachment = { - id: string; - drive_file_id: string; - name: string; - mime_type: string; - size: number; - url: string; - created_at: string; -}; - -export type TaskAttachmentListResponse = { - attachments: TaskAttachment[]; -}; - -export interface ApiPaths { - "/v1/auth/me": { - get: { - responses: { - 200: { content: { "application/json": User } }; - }; - }; - }; - "/v1/tenants": { - get: { - responses: { - 200: { content: { "application/json": Tenant[] } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects": { - get: { - parameters: { path: { tenant_id: string } }; - responses: { - 200: { content: { "application/json": Project[] } }; - }; - }; - post: { - parameters: { path: { tenant_id: string } }; - requestBody: { - content: { - "application/json": { - name: string; - description?: string; - key?: string; - }; - }; - }; - responses: { - 201: { content: { "application/json": Project } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{id}": { - get: { - parameters: { path: { tenant_id: string; id: string } }; - responses: { - 200: { content: { "application/json": Project } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/tasks": { - get: { - parameters: { - path: { tenant_id: string; project_id: string }; - query?: { - status_id?: string; - priority?: string; - limit?: number; - offset?: number; - }; - }; - responses: { - 200: { content: { "application/json": TaskListResponse } }; - }; - }; - post: { - parameters: { path: { tenant_id: string; project_id: string } }; - requestBody: { - content: { - "application/json": { - title: string; - description?: string; - priority?: TaskPriority; - status_id?: string; - }; - }; - }; - responses: { - 201: { content: { "application/json": Task } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/tasks/{id}": { - get: { - parameters: { - path: { tenant_id: string; project_id: string; id: string }; - }; - responses: { - 200: { content: { "application/json": Task } }; - }; - }; - put: { - parameters: { - path: { tenant_id: string; project_id: string; id: string }; - }; - requestBody: { - content: { - "application/json": { - title?: string; - description?: string; - status_id?: string; - priority?: TaskPriority; - }; - }; - }; - responses: { - 200: { content: { "application/json": Task } }; - }; - }; - delete: { - parameters: { - path: { tenant_id: string; project_id: string; id: string }; - }; - responses: { - 204: { content: never }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/tasks/search": { - get: { - parameters: { - path: { tenant_id: string; project_id: string }; - query: { q: string; limit?: number; offset?: number }; - }; - responses: { - 200: { content: { "application/json": SearchTasksResponse } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/tasks/bulk": { - post: { - parameters: { path: { tenant_id: string; project_id: string } }; - requestBody: { - content: { "application/json": BulkUpdateRequest }; - }; - responses: { - 200: { content: { "application/json": BulkUpdateResponse } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/task-views": { - get: { - parameters: { path: { tenant_id: string; project_id: string } }; - responses: { - 200: { content: { "application/json": TaskViewListResponse } }; - }; - }; - post: { - parameters: { path: { tenant_id: string; project_id: string } }; - requestBody: { - content: { - "application/json": { - name: string; - is_shared?: boolean; - filters?: Record; - sort?: Record; - view_type?: string; - }; - }; - }; - responses: { - 201: { content: { "application/json": TaskView } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/task-views/{view_id}": { - patch: { - parameters: { - path: { tenant_id: string; project_id: string; view_id: string }; - }; - requestBody: { - content: { - "application/json": { - name?: string; - is_shared?: boolean; - filters?: Record; - sort?: Record; - view_type?: string; - }; - }; - }; - responses: { - 200: { content: { "application/json": TaskView } }; - }; - }; - delete: { - parameters: { - path: { tenant_id: string; project_id: string; view_id: string }; - }; - responses: { - 204: { content: never }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/tasks/{id}/attachments": { - get: { - parameters: { - path: { tenant_id: string; project_id: string; id: string }; - }; - responses: { - 200: { content: { "application/json": TaskAttachmentListResponse } }; - }; - }; - post: { - parameters: { - path: { tenant_id: string; project_id: string; id: string }; - }; - requestBody: { - content: { "application/json": { drive_file_id: string } }; - }; - responses: { - 201: { content: { "application/json": TaskAttachment } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/tasks/{id}/attachments/{attachment_id}": { - delete: { - parameters: { - path: { - tenant_id: string; - project_id: string; - id: string; - attachment_id: string; - }; - }; - responses: { - 204: { content: never }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/tasks/{id}/comments": { - post: { - parameters: { - path: { tenant_id: string; project_id: string; id: string }; - }; - requestBody: { - content: { "application/json": { body: string } }; - }; - responses: { - 201: { content: { "application/json": Comment } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/statuses": { - get: { - parameters: { path: { tenant_id: string; project_id: string } }; - responses: { - 200: { content: { "application/json": ProjectStatus[] } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/sprints": { - get: { - parameters: { - path: { tenant_id: string; project_id: string }; - query?: { status?: string }; - }; - responses: { - 200: { content: { "application/json": Sprint[] } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/sprints/{id}": { - get: { - parameters: { - path: { tenant_id: string; project_id: string; id: string }; - }; - responses: { - 200: { content: { "application/json": SprintDetail } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/sprints/{id}/start": { - post: { - parameters: { - path: { tenant_id: string; project_id: string; id: string }; - }; - responses: { - 200: { content: { "application/json": Sprint } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/projects/{project_id}/sprints/{id}/complete": { - post: { - parameters: { - path: { tenant_id: string; project_id: string; id: string }; - }; - requestBody?: { - content: { - "application/json": { - move_incomplete_to_backlog?: boolean; - move_incomplete_to_sprint_id?: string; - }; - }; - }; - responses: { - 200: { content: { "application/json": Sprint } }; - }; - }; - }; - "/v1/tenants/{tenant_id}/users/me/tasks": { - get: { - parameters: { - path: { tenant_id: string }; - query?: { filter?: string; limit?: number; offset?: number }; - }; - responses: { - 200: { content: { "application/json": MyTasksListResponse } }; - }; - }; - }; -} +export type User = { + id: string; + username: string; + email: string; +}; + +export type Tenant = { + id: string; + name: string; + display_id: string; +}; + +export type Project = { + id: string; + tenant_id: string; + name: string; + key: string; + description?: string | null; + is_personal?: boolean; +}; + +export type TaskPriority = + | "critical_fire" + | "critical" + | "high" + | "medium" + | "low" + | "trivial"; + +export type Task = { + id: string; + project_id: string; + seq_id: number; + title: string; + description?: string | null; + status_id: string; + priority: TaskPriority; + sprint_id?: string | null; + soft_deadline?: string | null; + hard_deadline?: string | null; + completed_at?: string | null; +}; + +export type TaskListResponse = { + tasks: Task[]; + total: number; +}; + +export type ProjectStatus = { + id: string; + project_id: string; + name: string; + color: string; + is_done_state: boolean; +}; + +export type Sprint = { + id: string; + project_id: string; + name: string; + goal?: string | null; + status: string; + start_date: string; + end_date: string; +}; + +export type SprintDetail = { + sprint: Sprint; + task_counts: { total: number; done: number; in_progress: number }; + burndown: Array<{ + date: string; + ideal_remaining: number; + actual_remaining: number; + }>; +}; + +export type MyTaskItem = { + id: string; + seq_id: number; + seq_key: string; + title: string; + project: { id: string; name: string; key: string; is_personal: boolean }; + status: { id: string; name: string; color: string }; +}; + +export type MyTasksListResponse = { + tasks: MyTaskItem[]; + total: number; +}; + +export type Comment = { + id: string; + task_id: string; + body: string; + user_id: string; + created_at: string; +}; + +export type SearchTaskHit = { + id: string; + seq_id: number; + title: string; + highlight: string; + score: number; +}; + +export type SearchTasksResponse = { + tasks: SearchTaskHit[]; + total: number; +}; + +export type BulkUpdateFields = { + status_id?: string; + assignee_id?: string; + add_label_ids?: string[]; + remove_label_ids?: string[]; + sprint_id?: string; + clear_sprint_id?: boolean; +}; + +export type BulkUpdateRequest = { + task_ids: string[]; + update: BulkUpdateFields; +}; + +export type BulkFailure = { + task_id: string; + reason: string; +}; + +export type BulkUpdateResponse = { + updated: number; + failed: BulkFailure[]; +}; + +export type TaskView = { + id: string; + project_id: string; + created_by: string; + name: string; + is_shared: boolean; + filters: Record; + sort: Record; + view_type: string; + created_at: string; +}; + +export type TaskViewListResponse = { + views: TaskView[]; +}; + +export type TaskAttachment = { + id: string; + drive_file_id: string; + name: string; + mime_type: string; + size: number; + url: string; + created_at: string; +}; + +export type TaskAttachmentListResponse = { + attachments: TaskAttachment[]; +}; + +export interface ApiPaths { + "/v1/auth/me": { + get: { + responses: { + 200: { content: { "application/json": User } }; + }; + }; + }; + "/v1/tenants": { + get: { + responses: { + 200: { content: { "application/json": Tenant[] } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects": { + get: { + parameters: { path: { tenant_id: string } }; + responses: { + 200: { content: { "application/json": Project[] } }; + }; + }; + post: { + parameters: { path: { tenant_id: string } }; + requestBody: { + content: { + "application/json": { + name: string; + description?: string; + key?: string; + }; + }; + }; + responses: { + 201: { content: { "application/json": Project } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{id}": { + get: { + parameters: { path: { tenant_id: string; id: string } }; + responses: { + 200: { content: { "application/json": Project } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/tasks": { + get: { + parameters: { + path: { tenant_id: string; project_id: string }; + query?: { + status_id?: string; + priority?: string; + limit?: number; + offset?: number; + }; + }; + responses: { + 200: { content: { "application/json": TaskListResponse } }; + }; + }; + post: { + parameters: { path: { tenant_id: string; project_id: string } }; + requestBody: { + content: { + "application/json": { + title: string; + description?: string; + priority?: TaskPriority; + status_id?: string; + }; + }; + }; + responses: { + 201: { content: { "application/json": Task } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/tasks/{id}": { + get: { + parameters: { + path: { tenant_id: string; project_id: string; id: string }; + }; + responses: { + 200: { content: { "application/json": Task } }; + }; + }; + put: { + parameters: { + path: { tenant_id: string; project_id: string; id: string }; + }; + requestBody: { + content: { + "application/json": { + title?: string; + description?: string; + status_id?: string; + priority?: TaskPriority; + }; + }; + }; + responses: { + 200: { content: { "application/json": Task } }; + }; + }; + delete: { + parameters: { + path: { tenant_id: string; project_id: string; id: string }; + }; + responses: { + 204: { content: never }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/tasks/search": { + get: { + parameters: { + path: { tenant_id: string; project_id: string }; + query: { q: string; limit?: number; offset?: number }; + }; + responses: { + 200: { content: { "application/json": SearchTasksResponse } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/tasks/bulk": { + post: { + parameters: { path: { tenant_id: string; project_id: string } }; + requestBody: { + content: { "application/json": BulkUpdateRequest }; + }; + responses: { + 200: { content: { "application/json": BulkUpdateResponse } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/task-views": { + get: { + parameters: { path: { tenant_id: string; project_id: string } }; + responses: { + 200: { content: { "application/json": TaskViewListResponse } }; + }; + }; + post: { + parameters: { path: { tenant_id: string; project_id: string } }; + requestBody: { + content: { + "application/json": { + name: string; + is_shared?: boolean; + filters?: Record; + sort?: Record; + view_type?: string; + }; + }; + }; + responses: { + 201: { content: { "application/json": TaskView } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/task-views/{view_id}": { + patch: { + parameters: { + path: { tenant_id: string; project_id: string; view_id: string }; + }; + requestBody: { + content: { + "application/json": { + name?: string; + is_shared?: boolean; + filters?: Record; + sort?: Record; + view_type?: string; + }; + }; + }; + responses: { + 200: { content: { "application/json": TaskView } }; + }; + }; + delete: { + parameters: { + path: { tenant_id: string; project_id: string; view_id: string }; + }; + responses: { + 204: { content: never }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/tasks/{id}/attachments": { + get: { + parameters: { + path: { tenant_id: string; project_id: string; id: string }; + }; + responses: { + 200: { content: { "application/json": TaskAttachmentListResponse } }; + }; + }; + post: { + parameters: { + path: { tenant_id: string; project_id: string; id: string }; + }; + requestBody: { + content: { "application/json": { drive_file_id: string } }; + }; + responses: { + 201: { content: { "application/json": TaskAttachment } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/tasks/{id}/attachments/{attachment_id}": { + delete: { + parameters: { + path: { + tenant_id: string; + project_id: string; + id: string; + attachment_id: string; + }; + }; + responses: { + 204: { content: never }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/tasks/{id}/comments": { + post: { + parameters: { + path: { tenant_id: string; project_id: string; id: string }; + }; + requestBody: { + content: { "application/json": { body: string } }; + }; + responses: { + 201: { content: { "application/json": Comment } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/statuses": { + get: { + parameters: { path: { tenant_id: string; project_id: string } }; + responses: { + 200: { content: { "application/json": ProjectStatus[] } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/sprints": { + get: { + parameters: { + path: { tenant_id: string; project_id: string }; + query?: { status?: string }; + }; + responses: { + 200: { content: { "application/json": Sprint[] } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/sprints/{id}": { + get: { + parameters: { + path: { tenant_id: string; project_id: string; id: string }; + }; + responses: { + 200: { content: { "application/json": SprintDetail } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/sprints/{id}/start": { + post: { + parameters: { + path: { tenant_id: string; project_id: string; id: string }; + }; + responses: { + 200: { content: { "application/json": Sprint } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/projects/{project_id}/sprints/{id}/complete": { + post: { + parameters: { + path: { tenant_id: string; project_id: string; id: string }; + }; + requestBody?: { + content: { + "application/json": { + move_incomplete_to_backlog?: boolean; + move_incomplete_to_sprint_id?: string; + }; + }; + }; + responses: { + 200: { content: { "application/json": Sprint } }; + }; + }; + }; + "/v1/tenants/{tenant_id}/users/me/tasks": { + get: { + parameters: { + path: { tenant_id: string }; + query?: { filter?: string; limit?: number; offset?: number }; + }; + responses: { + 200: { content: { "application/json": MyTasksListResponse } }; + }; + }; + }; +} diff --git a/apps/frontend/openapi.json b/apps/frontend/openapi.json index 76319cbc6..34a8aa911 100644 --- a/apps/frontend/openapi.json +++ b/apps/frontend/openapi.json @@ -18579,6 +18579,14 @@ "clear_sprint_id": { "type": "boolean" }, + "remove_label_ids": { + "type": ["array", "null"], + "items": { + "type": "string", + "format": "uuid" + }, + "description": "既存ラベルから外す ID 一覧。未付与・プロジェクト外の ID は単に無視される。\nadd_label_ids と同じ ID を含む場合は 400" + }, "sprint_id": { "type": ["string", "null"], "format": "uuid"