Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 2 additions & 120 deletions apps/backend/crates/handler/src/handlers/my_tasks.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
};
use axum_valid::Valid;
use chrono::{Duration, NaiveDate, Utc};
use sea_orm::{
ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, ConnectionTrait, EntityTrait,
JoinType, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait,
TransactionTrait, prelude::Uuid, sea_query::LockType,
TransactionTrait, prelude::Uuid,
};

use crate::AppState;
Expand All @@ -17,52 +15,16 @@ use crate::extractors::AuthUser;
use crate::openapi::CrudErrors;
use entity::{
drive_folders, project_members, project_statuses, project_task_counters, projects,
scopes::Scope, task_assignees, tasks, users,
scopes::Scope, tasks, users,
};
use payload::my_tasks::*;
use payload::projects::ProjectResponse;
use payload::tasks::TaskResponse;
use service::db::is_postgres_unique_violation;
use service::task_activities::record_activity;
fn personal_project_key(user_id: Uuid) -> String {
let id_hex = user_id.simple().to_string().to_ascii_uppercase();
format!("ME{}", &id_hex[..4])
}

async fn next_seq_id(db: &sea_orm::DatabaseTransaction, project_id: Uuid) -> Result<i32, AppError> {
let existing = project_task_counters::Entity::find_by_id(project_id)
.lock(LockType::Update)
.one(db)
.await?;
Ok(match existing {
Some(c) => {
let new_seq = c.last_seq + 1;
let mut active: project_task_counters::ActiveModel = c.into();
active.last_seq = Set(new_seq);
active.update(db).await?.last_seq
}
None => {
project_task_counters::ActiveModel {
project_id: Set(project_id),
last_seq: Set(1),
}
.insert(db)
.await?
.last_seq
}
})
}

async fn default_status_id<C: ConnectionTrait>(db: &C, project_id: Uuid) -> Result<Uuid, AppError> {
project_statuses::Entity::find()
.filter(project_statuses::Column::ProjectId.eq(project_id))
.filter(project_statuses::Column::IsDefault.eq(true))
.one(db)
.await?
.map(|s| s.id)
.ok_or(AppError::NotFound)
}

async fn seed_personal_project_defaults<C: ConnectionTrait>(
db: &C,
project_id: Uuid,
Expand Down Expand Up @@ -419,83 +381,3 @@ pub async fn list_my_tasks(
total,
}))
}

#[axum::debug_handler]
#[utoipa::path(
post,
path = "/tasks",
tag = "My Tasks",
summary = "クイックキャプチャ(個人プロジェクトへタスク作成)",
params(("tenant_id" = Uuid, Path, description = "テナントID")),
request_body = QuickCaptureRequest,
responses(
(status = 201, description = "作成されたタスク", body = TaskResponse),
CrudErrors,
)
)]
pub async fn create_my_task(
State(state): State<AppState>,
auth: AuthUser,
Path(tenant_id): Path<Uuid>,
Valid(Json(payload)): Valid<Json<QuickCaptureRequest>>,
) -> Result<(StatusCode, Json<TaskResponse>), AppError> {
auth.require_scope(Scope::WriteTask)?;
auth.ensure_tenant_access(&state, tenant_id, None).await?;

let personal = get_or_create_personal_project(&state, tenant_id, auth.user_id).await?;
let status_id = default_status_id(&state.db, personal.id).await?;

let txn = state.db.begin().await?;
let seq_id = next_seq_id(&txn, personal.id).await?;
let priority = payload.priority.unwrap_or(tasks::TaskPriority::Medium);

let model = tasks::ActiveModel {
id: Set(Uuid::new_v4()),
project_id: Set(personal.id),
seq_id: Set(seq_id),
title: Set(payload.title),
description: Set(payload.note),
status_id: Set(status_id),
priority: Set(priority),
progress_pct: Set(0),
parent_task_id: Set(None),
milestone_id: Set(None),
sprint_id: Set(None),
soft_deadline: Set(payload.soft_deadline.map(Into::into)),
hard_deadline: Set(None),
estimated_minutes: Set(None),
is_archived: Set(false),
created_by: Set(auth.user_id),
created_at: Set(Utc::now().into()),
updated_at: Set(Utc::now().into()),
completed_at: Set(None),
deleted_at: Set(None),
}
.insert(&txn)
.await?;

task_assignees::ActiveModel {
id: Set(Uuid::new_v4()),
task_id: Set(model.id),
user_id: Set(auth.user_id),
role: Set("assignee".into()),
assigned_at: Set(Utc::now().into()),
}
.insert(&txn)
.await?;

record_activity(
&txn,
model.id,
Some(auth.user_id),
"task_created",
serde_json::json!({}),
)
.await?;

txn.commit().await?;
Ok((
StatusCode::CREATED,
Json(service::task_responses::build_task_response(&state.db, model).await?),
))
}
3 changes: 1 addition & 2 deletions apps/backend/crates/handler/src/routes/tenants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ pub fn routes() -> OpenApiRouter<AppState> {
"/{tenant_id}/users/me",
OpenApiRouter::<AppState>::new()
.routes(routes!(crate::handlers::my_tasks::get_personal_project))
.routes(routes!(crate::handlers::my_tasks::list_my_tasks))
.routes(routes!(crate::handlers::my_tasks::create_my_task)),
.routes(routes!(crate::handlers::my_tasks::list_my_tasks)),
)
}
10 changes: 0 additions & 10 deletions apps/backend/crates/payload/src/my_tasks.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use sea_orm::prelude::Uuid;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use validator::Validate;

use entity::tasks;

Expand Down Expand Up @@ -31,15 +30,6 @@ fn default_limit() -> u64 {
50
}

#[derive(Validate, Deserialize, ToSchema)]
pub struct QuickCaptureRequest {
#[validate(length(min = 1, max = 255))]
pub title: String,
pub soft_deadline: Option<chrono::DateTime<chrono::Utc>>,
pub priority: Option<tasks::TaskPriority>,
pub note: Option<String>,
}

#[derive(Serialize, ToSchema)]
pub struct MyTaskProjectInfo {
#[schema(value_type = String, format = "uuid")]
Expand Down
54 changes: 45 additions & 9 deletions apps/backend/tests/my_tasks_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ async fn personal_project_is_idempotent() {
assert_eq!(first, second);
}

/// quick-capture API 撤去の回帰テスト(#363)。
/// 撤去前は 201 CREATED を返していたため、撤去前のコードでは fail する。
#[tokio::test]
async fn quick_capture_and_list() {
async fn quick_capture_endpoint_is_removed() {
let mut app = TestApp::new().await;
let (_user, tp) = setup(&mut app).await;
let base = my_tasks_base(tp.tenant_id);
Expand All @@ -80,21 +82,55 @@ async fn quick_capture_and_list() {
)
.await
.status(),
StatusCode::METHOD_NOT_ALLOWED
);
}

#[tokio::test]
async fn list_returns_only_assigned_tasks() {
let mut app = TestApp::new().await;
let (user, tp) = setup(&mut app).await;
let status_id = create_status(&app, &tp, "Todo", false).await;
let path = format!(
"/v1/tenants/{}/projects/{}/tasks",
tp.tenant_id, tp.project_id
);
let assignee = serde_json::json!([{"user_id": user.id, "role": "assignee"}]);
// 成功系: 自分に割り当てられたタスクは一覧に載る
assert_eq!(
app.post_json_with_session(
&path,
serde_json::json!({"title": "Assigned", "status_id": status_id, "assignees": assignee}),
)
.await
.status(),
StatusCode::CREATED
);
// 対照: 未割り当てのタスクは載らない
assert_eq!(
app.post_json_with_session(
&path,
serde_json::json!({"title": "Unassigned", "status_id": status_id}),
)
.await
.status(),
StatusCode::CREATED
);
let body: serde_json::Value = app
.get_with_session(&format!("{base}/tasks?filter=all"))
.get_with_session(&format!("{}/tasks?filter=all", my_tasks_base(tp.tenant_id)))
.await
.json()
.await
.unwrap();
assert!(
body["tasks"]
.as_array()
.unwrap()
.iter()
.any(|t| t["title"] == "Buy milk")
);
let titles: Vec<&str> = body["tasks"]
.as_array()
.unwrap()
.iter()
.filter_map(|t| t["title"].as_str())
.collect();
assert!(titles.contains(&"Assigned"));
assert!(!titles.contains(&"Unassigned"));
assert_eq!(body["total"], 1);
}

#[tokio::test]
Expand Down
16 changes: 0 additions & 16 deletions apps/cli/src/api/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,21 +468,5 @@ export interface ApiPaths {
200: { content: { "application/json": MyTasksListResponse } };
};
};
post: {
parameters: { path: { tenant_id: string } };
requestBody: {
content: {
"application/json": {
title: string;
soft_deadline?: string;
priority?: TaskPriority;
note?: string;
};
};
};
responses: {
201: { content: { "application/json": Task } };
};
};
};
}
10 changes: 6 additions & 4 deletions apps/cli/src/commands/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,15 @@ describe("command registration and primary branches", () => {
expect(mocks.saveConfigFile).toHaveBeenCalledWith({ tenant_id: "tenant-2" });
});

it("my parses add and sends its title and priority", async () => {
it("my parses list and sends its filter", async () => {
await programWith(registerMyCommands).parseAsync([
"node", "task", "my", "add", "Golden task", "--priority", "high",
"node", "task", "my", "list", "--filter", "today",
]);
expect(mocks.POST).toHaveBeenCalledWith(
expect(mocks.GET).toHaveBeenCalledWith(
"/v1/tenants/{tenant_id}/users/me/tasks",
expect.objectContaining({ body: { title: "Golden task", priority: "high" } }),
expect.objectContaining({
params: expect.objectContaining({ query: { filter: "today" } }),
}),
);
});

Expand Down
25 changes: 0 additions & 25 deletions apps/cli/src/commands/my.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Command } from "commander";
import { getClient, getTenantId } from "../api/client";
import type { TaskPriority } from "../api/paths";
import { getOutputOptions } from "../utils/command";
import type { OutputOptions } from "../utils/output";
import { print } from "../utils/output";
Expand All @@ -10,7 +9,6 @@ import { findDoneStatusId } from "../utils/statuses";

type MyCommandOptions = OutputOptions & {
filter?: string;
priority?: string;
};

export function registerMyCommands(program: Command): void {
Expand All @@ -37,29 +35,6 @@ export function registerMyCommands(program: Command): void {
print(unwrapApiResult(result), output);
});

my
.command("add")
.description("Quick-capture a task to personal inbox")
.argument("<title>", "Task title")
.option("--priority <priority>", "Task priority")
.action(async (title: string, opts: MyCommandOptions, cmd) => {
const output = getOutputOptions(cmd);
const client = getClient();
const tenantId = getTenantId();
const body: {
title: string;
priority?: TaskPriority;
} = { title };
if (opts.priority) {
body.priority = opts.priority as TaskPriority;
}
const result = await client.POST("/v1/tenants/{tenant_id}/users/me/tasks", {
params: { path: { tenant_id: tenantId } },
body,
});
print(unwrapApiResult(result), output);
});

my
.command("complete")
.description("Complete a personal or assigned task by ref (e.g. ME-3)")
Expand Down
Loading
Loading