Skip to content

Commit 285604c

Browse files
committed
feature: implement business task intake flow
1 parent bfb70fa commit 285604c

21 files changed

Lines changed: 867 additions & 56 deletions

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,17 @@ The current codebase proves the delivery path with a small working slice:
4141

4242
- `GET /api/health`
4343
- `GET /api/tasks`
44-
- Vue rendering of live backend health and seeded MariaDB task rows
45-
- Playwright smoke coverage for API health, API task reads, and the home page
44+
- `POST /api/tasks` with validated business planning fields
45+
- Vue rendering of live backend health, business-task intake, and planning-oriented task rows
46+
- Playwright smoke coverage for API health, API task reads, API task creation, and UI create/read paths
4647
- Local Docker and `k3d` validation through the Helm chart
4748

4849
## Documented Next Feature
4950

5051
The task lifecycle package under `docs/` is intentionally the next planned implementation step, not a claim that it already exists in code.
5152

52-
- task creation via `POST /api/tasks`
5353
- constrained status transitions via `PATCH /api/tasks/{id}`
54+
- lifecycle rules layered on top of the existing task intake flow
5455
- `blockedReason` validation and UI treatment
5556
- richer task lifecycle e2e scenarios
5657
- Liquibase schema expansion for `blocked_reason` and `updated_at`
@@ -129,8 +130,8 @@ The supporting note for that workflow lives in [`docs/ai-workflow.md`](docs/ai-w
129130

130131
Current documentation examples:
131132

133+
- Implemented business-task slice: [`docs/prd/business-task-intake-and-planning.md`](docs/prd/business-task-intake-and-planning.md) and [`docs/spec/business-task-intake-and-planning.md`](docs/spec/business-task-intake-and-planning.md)
132134
- Planned implementation slice: [`docs/prd/task-lifecycle-and-status-rules.md`](docs/prd/task-lifecycle-and-status-rules.md) and [`docs/spec/task-lifecycle-and-status-rules.md`](docs/spec/task-lifecycle-and-status-rules.md)
133-
- PRD-stage business planning slice: [`docs/prd/business-task-intake-and-planning.md`](docs/prd/business-task-intake-and-planning.md)
134135

135136
## Workspace Shape
136137

Lines changed: 118 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
package com.example.demo
22

3+
import jakarta.validation.Valid
4+
import jakarta.validation.constraints.NotBlank
5+
import jakarta.validation.constraints.NotNull
6+
import jakarta.validation.constraints.Size
7+
import org.springframework.http.HttpStatus
38
import org.springframework.jdbc.core.simple.JdbcClient
9+
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
10+
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
11+
import org.springframework.jdbc.support.GeneratedKeyHolder
12+
import org.springframework.web.bind.annotation.PostMapping
13+
import org.springframework.web.bind.annotation.RequestBody
414
import org.springframework.web.bind.annotation.GetMapping
515
import org.springframework.web.bind.annotation.RequestMapping
16+
import org.springframework.web.bind.annotation.ResponseStatus
617
import org.springframework.web.bind.annotation.RestController
18+
import java.time.LocalDate
719

820
@RestController
921
@RequestMapping("/api/tasks")
@@ -13,34 +25,130 @@ class SampleTaskController(
1325

1426
@GetMapping
1527
fun listTasks(): List<SampleTaskResponse> = sampleTaskService.findAll()
28+
29+
@PostMapping
30+
@ResponseStatus(HttpStatus.CREATED)
31+
fun createTask(@Valid @RequestBody request: CreateBusinessTaskRequest): SampleTaskResponse {
32+
return sampleTaskService.createTask(request)
33+
}
1634
}
1735

1836
@org.springframework.stereotype.Service
1937
class SampleTaskService(
20-
private val jdbcClient: JdbcClient
38+
private val jdbcClient: JdbcClient,
39+
private val namedParameterJdbcTemplate: NamedParameterJdbcTemplate
2140
) {
2241

2342
fun findAll(): List<SampleTaskResponse> {
2443
return jdbcClient.sql(
2544
"""
26-
SELECT id, title, status, created_at
45+
SELECT id, title, status, created_at, customer_request, requested_work,
46+
target_delivery_date, build_estimate, owner_name
2747
FROM sample_task
2848
ORDER BY id
2949
""".trimIndent()
30-
).query { rs, _ ->
31-
SampleTaskResponse(
32-
id = rs.getLong("id"),
33-
title = rs.getString("title"),
34-
status = rs.getString("status"),
35-
createdAt = rs.getTimestamp("created_at").toInstant().toString()
50+
).query(::mapTask).list()
51+
}
52+
53+
fun createTask(request: CreateBusinessTaskRequest): SampleTaskResponse {
54+
val normalizedRequestedWork = request.requestedWork.requireText()
55+
val keyHolder = GeneratedKeyHolder()
56+
57+
namedParameterJdbcTemplate.update(
58+
"""
59+
INSERT INTO sample_task (
60+
title,
61+
status,
62+
customer_request,
63+
requested_work,
64+
target_delivery_date,
65+
build_estimate,
66+
owner_name
67+
) VALUES (
68+
:title,
69+
:status,
70+
:customerRequest,
71+
:requestedWork,
72+
:targetDeliveryDate,
73+
:buildEstimate,
74+
:ownerName
3675
)
37-
}.list()
76+
""".trimIndent(),
77+
MapSqlParameterSource()
78+
.addValue("title", toTitle(normalizedRequestedWork))
79+
.addValue("status", "TODO")
80+
.addValue("customerRequest", request.customerRequest.requireText())
81+
.addValue("requestedWork", normalizedRequestedWork)
82+
.addValue("targetDeliveryDate", requireNotNull(request.targetDeliveryDate))
83+
.addValue("buildEstimate", request.buildEstimate.requireText())
84+
.addValue("ownerName", request.owner.requireText()),
85+
keyHolder,
86+
arrayOf("id")
87+
)
88+
89+
val taskId = keyHolder.key?.toLong() ?: error("Task insert did not return an ID")
90+
return findById(taskId) ?: error("Task $taskId was created but could not be read back")
91+
}
92+
93+
private fun findById(id: Long): SampleTaskResponse? {
94+
return jdbcClient.sql(
95+
"""
96+
SELECT id, title, status, created_at, customer_request, requested_work,
97+
target_delivery_date, build_estimate, owner_name
98+
FROM sample_task
99+
WHERE id = :id
100+
""".trimIndent()
101+
).param("id", id).query(::mapTask).optional().orElse(null)
102+
}
103+
104+
@Suppress("UNUSED_PARAMETER")
105+
private fun mapTask(rs: java.sql.ResultSet, rowNumber: Int): SampleTaskResponse {
106+
return SampleTaskResponse(
107+
id = rs.getLong("id"),
108+
title = rs.getString("title"),
109+
status = rs.getString("status"),
110+
createdAt = rs.getTimestamp("created_at").toInstant().toString(),
111+
customerRequest = rs.getString("customer_request"),
112+
requestedWork = rs.getString("requested_work"),
113+
targetDeliveryDate = rs.getDate("target_delivery_date").toLocalDate().toString(),
114+
buildEstimate = rs.getString("build_estimate"),
115+
owner = rs.getString("owner_name")
116+
)
117+
}
118+
119+
private fun toTitle(requestedWork: String): String {
120+
val compact = requestedWork.replace("\\s+".toRegex(), " ").trim()
121+
return if (compact.length <= 100) compact else compact.take(97).trimEnd() + "..."
38122
}
39123
}
40124

125+
data class CreateBusinessTaskRequest(
126+
@field:NotBlank(message = "Customer request is required")
127+
@field:Size(max = 255, message = "Customer request must be 255 characters or fewer")
128+
val customerRequest: String?,
129+
@field:NotBlank(message = "Requested work is required")
130+
@field:Size(max = 255, message = "Requested work must be 255 characters or fewer")
131+
val requestedWork: String?,
132+
@field:NotNull(message = "Delivery date is required")
133+
val targetDeliveryDate: LocalDate?,
134+
@field:NotBlank(message = "Build estimate is required")
135+
@field:Size(max = 60, message = "Build estimate must be 60 characters or fewer")
136+
val buildEstimate: String?,
137+
@field:NotBlank(message = "Owner is required")
138+
@field:Size(max = 80, message = "Owner must be 80 characters or fewer")
139+
val owner: String?
140+
)
141+
41142
data class SampleTaskResponse(
42143
val id: Long,
43144
val title: String,
44145
val status: String,
45-
val createdAt: String
146+
val createdAt: String,
147+
val customerRequest: String,
148+
val requestedWork: String,
149+
val targetDeliveryDate: String,
150+
val buildEstimate: String,
151+
val owner: String
46152
)
153+
154+
private fun String?.requireText(): String = requireNotNull(this).trim()
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.example.demo
2+
3+
import org.springframework.http.HttpStatus
4+
import org.springframework.http.converter.HttpMessageNotReadableException
5+
import org.springframework.web.bind.MethodArgumentNotValidException
6+
import org.springframework.web.bind.annotation.ExceptionHandler
7+
import org.springframework.web.bind.annotation.ResponseStatus
8+
import org.springframework.web.bind.annotation.RestControllerAdvice
9+
10+
@RestControllerAdvice
11+
class TaskApiExceptionHandler {
12+
13+
@ExceptionHandler(MethodArgumentNotValidException::class)
14+
@ResponseStatus(HttpStatus.BAD_REQUEST)
15+
fun handleValidationFailure(exception: MethodArgumentNotValidException): TaskApiErrorResponse {
16+
return TaskApiErrorResponse(
17+
code = "VALIDATION_ERROR",
18+
message = "Task input is invalid",
19+
fieldErrors = exception.bindingResult.fieldErrors.associate { fieldError ->
20+
fieldError.field to (fieldError.defaultMessage ?: "Invalid value")
21+
}
22+
)
23+
}
24+
25+
@ExceptionHandler(HttpMessageNotReadableException::class)
26+
@ResponseStatus(HttpStatus.BAD_REQUEST)
27+
fun handleUnreadableBody(): TaskApiErrorResponse {
28+
return TaskApiErrorResponse(
29+
code = "INVALID_REQUEST",
30+
message = "Request body could not be read",
31+
fieldErrors = emptyMap()
32+
)
33+
}
34+
}
35+
36+
data class TaskApiErrorResponse(
37+
val code: String,
38+
val message: String,
39+
val fieldErrors: Map<String, String>
40+
)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
databaseChangeLog:
2+
- changeSet:
3+
id: 0003-add-business-task-planning-columns
4+
author: codex
5+
preConditions:
6+
- onFail: MARK_RAN
7+
- not:
8+
- columnExists:
9+
tableName: sample_task
10+
columnName: customer_request
11+
changes:
12+
- addColumn:
13+
tableName: sample_task
14+
columns:
15+
- column:
16+
name: customer_request
17+
type: varchar(255)
18+
- column:
19+
name: requested_work
20+
type: varchar(255)
21+
- column:
22+
name: target_delivery_date
23+
type: date
24+
- column:
25+
name: build_estimate
26+
type: varchar(60)
27+
- column:
28+
name: owner_name
29+
type: varchar(80)
30+
- sql:
31+
sql: |
32+
UPDATE sample_task
33+
SET
34+
customer_request = CASE
35+
WHEN title = 'Wire backend to frontend' THEN 'Frontend review needs live task data from the backend'
36+
WHEN title = 'Prepare k3s test namespace' THEN 'Platform review needs a reusable QA namespace check'
37+
ELSE 'Internal delivery planning request'
38+
END,
39+
requested_work = CASE
40+
WHEN title = 'Wire backend to frontend' THEN 'Connect the Vue task board to backend task APIs'
41+
WHEN title = 'Prepare k3s test namespace' THEN 'Prepare a k3s namespace smoke path for PR environments'
42+
ELSE title
43+
END,
44+
target_delivery_date = CASE
45+
WHEN title = 'Wire backend to frontend' THEN '2026-03-28'
46+
WHEN title = 'Prepare k3s test namespace' THEN '2026-03-25'
47+
ELSE '2026-03-31'
48+
END,
49+
build_estimate = CASE
50+
WHEN title = 'Wire backend to frontend' THEN '2 engineering days'
51+
WHEN title = 'Prepare k3s test namespace' THEN '1 engineering day'
52+
ELSE 'TBD'
53+
END,
54+
owner_name = 'Sky'
55+
WHERE customer_request IS NULL
56+
OR requested_work IS NULL
57+
OR target_delivery_date IS NULL
58+
OR build_estimate IS NULL
59+
OR owner_name IS NULL;
60+
- addNotNullConstraint:
61+
tableName: sample_task
62+
columnName: customer_request
63+
columnDataType: varchar(255)
64+
- addNotNullConstraint:
65+
tableName: sample_task
66+
columnName: requested_work
67+
columnDataType: varchar(255)
68+
- addNotNullConstraint:
69+
tableName: sample_task
70+
columnName: target_delivery_date
71+
columnDataType: date
72+
- addNotNullConstraint:
73+
tableName: sample_task
74+
columnName: build_estimate
75+
columnDataType: varchar(60)
76+
- addNotNullConstraint:
77+
tableName: sample_task
78+
columnName: owner_name
79+
columnDataType: varchar(80)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
databaseChangeLog:
22
- include:
33
file: db/liquibase/changelog/changes/0001-init.yaml
4+
- include:
5+
file: db/liquibase/changelog/changes/0002-business-task-planning.yaml

0 commit comments

Comments
 (0)