Module: issues¶
Purpose¶
Manages the full lifecycle of issues (bugs, stories, tasks, epics, subtasks). Owns CRUD, priority, type, inter-issue linking, and field updates.
Entities Owned¶
| Entity | Table | Key Fields |
|---|---|---|
Issue |
issues |
key VARCHAR(20) UNIQUE (e.g. TW-5), keyNumber INT, title VARCHAR(500) NOT NULL, description TEXT, type: IssueType, priority: IssuePriority, storyPoints: Int?, status FK→workflow_statuses NOT NULL, project FK→projects NOT NULL, assignee FK→users nullable, reporter FK→users NOT NULL, sprint FK→sprints nullable, parent FK→issues nullable (self-referential), dueDate: LocalDate?, slaStartTime: Instant?, labels: MutableSet<Label> (@ManyToMany LAZY, join table issue_labels, owned by Issue) |
IssueLink |
issue_links |
fromIssue FK→issues NOT NULL, toIssue FK→issues NOT NULL, linkType: IssueLinkType NOT NULL; UNIQUE (from_issue_id, to_issue_id, link_type) |
IssueType enum values: EPIC, STORY, BUG, TASK, SUBTASK.
IssuePriority enum values: CRITICAL, HIGH, MEDIUM, LOW.
IssueLinkType enum values: BLOCKS, BLOCKED_BY, RELATES_TO, DUPLICATES, CLONED_BY.
DB Schema¶
issues (V4)¶
CREATE TABLE issues (
id UUID NOT NULL PRIMARY KEY,
"key" VARCHAR(20) NOT NULL UNIQUE,
key_number INT NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
type VARCHAR(20) NOT NULL DEFAULT 'TASK',
priority VARCHAR(20) NOT NULL DEFAULT 'MEDIUM',
story_points INT,
status_id UUID NOT NULL REFERENCES workflow_statuses(id),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
assignee_id UUID REFERENCES users(id),
reporter_id UUID NOT NULL REFERENCES users(id),
sprint_id UUID,
parent_id UUID REFERENCES issues(id),
due_date DATE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
sprint_id has no FK constraint in V4 (added by the sprints module migration).
Indexes: idx_issues_project on project_id, idx_issues_assignee on assignee_id, idx_issues_status on status_id, idx_issues_parent on parent_id.
issue_links (V4)¶
| Column | Type | Constraint |
|---|---|---|
id |
UUID | PK |
from_issue_id |
UUID | FK→issues ON DELETE CASCADE |
to_issue_id |
UUID | FK→issues ON DELETE CASCADE |
link_type |
VARCHAR(30) | NOT NULL |
Unique constraint: (from_issue_id, to_issue_id, link_type).
Indexes: idx_issue_links_from on from_issue_id, idx_issue_links_to on to_issue_id.
labels and issue_labels (V23)¶
See the labels module for the full schema. issue_labels is the join table declared via @JoinTable on Issue.labels.
API Endpoints¶
IssueController — /api/v1/projects/{key}/issues¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/projects/{key}/issues |
USER | Lists issues paginated (page, size); optional assigneeMe=true, sort=updatedAt, overdue=true, labelId=<UUID> (single-label filter); refs[] is empty on list; labels[] is empty on list |
| POST | /api/v1/projects/{key}/issues |
USER | Creates an issue; key is auto-generated as {PROJECT_KEY}-{nextNumber}; publishes IssueCreatedEvent |
| GET | /api/v1/projects/{key}/issues/{issueKey} |
USER | Returns single issue by issueKey (e.g. TW-5); refs[] is populated from IssueRefRepository |
| PATCH | /api/v1/projects/{key}/issues/{id} |
USER | Partial update by issue UUID; labelIds: List<UUID>? replaces the full label set (null = no change, [] = remove all); publishes IssueFieldChangedEvent or IssueStatusChangedEvent per changed field |
| DELETE | /api/v1/projects/{key}/issues/{id} |
USER | Deletes issue by UUID; verifies issue belongs to the project |
{key} in all paths is the project key (e.g. TW). The GET single endpoint uses {issueKey} (e.g. TW-5) while update and delete use the issue UUID {id}.
Events Emitted¶
IssueCreatedEvent¶
Published by IssueService.create() after saving a new issue.
| Field | Type | Description |
|---|---|---|
issue |
Issue |
The newly created issue entity |
actorEmail |
String |
Email of the user or sender (for email-ingested tickets) |
actorId |
UUID |
ID of the reporter |
IssueStatusChangedEvent¶
Published by IssueService.update() when statusId changes and WorkflowService.validateTransition() passes.
| Field | Type | Description |
|---|---|---|
issue |
Issue |
The issue after the status change |
oldStatus |
WorkflowStatus |
Previous status entity |
newStatus |
WorkflowStatus |
New status entity |
actor |
User? |
The user who triggered the change; nullable for programmatic transitions |
IssueFieldChangedEvent¶
Published by IssueService.update() once per changed field (title, description, priority, storyPoints, assignee, type, dueDate, sprint).
| Field | Type | Description |
|---|---|---|
issue |
Issue |
The issue being modified |
actor |
User |
The authenticated user performing the update |
field |
String |
Field name as a string (e.g. "title", "priority", "assignee") |
oldValue |
String? |
Previous value serialized to string; null if previously unset |
newValue |
String? |
New value serialized to string; null when clearing a field |
Events Consumed¶
None. No @EventListener annotations exist in issues/application/IssueService.kt.
Key Files¶
| File | Responsibility |
|---|---|
backend/src/main/kotlin/com/taskowolf/issues/domain/Issue.kt |
@Entity for issues; all mutable fields use var; key, keyNumber, project, reporter are val (set on create, never changed) |
backend/src/main/kotlin/com/taskowolf/issues/domain/IssueLink.kt |
@Entity for issue_links; all fields are val (links are immutable after creation) |
backend/src/main/kotlin/com/taskowolf/issues/domain/IssueLinkType.kt |
Enum { BLOCKS, BLOCKED_BY, RELATES_TO, DUPLICATES, CLONED_BY } |
backend/src/main/kotlin/com/taskowolf/issues/domain/IssuePriority.kt |
Enum { CRITICAL, HIGH, MEDIUM, LOW } |
backend/src/main/kotlin/com/taskowolf/issues/domain/IssueType.kt |
Enum { EPIC, STORY, BUG, TASK, SUBTASK } |
backend/src/main/kotlin/com/taskowolf/issues/domain/events/IssueCreatedEvent.kt |
Data class published on issue creation |
backend/src/main/kotlin/com/taskowolf/issues/domain/events/IssueStatusChangedEvent.kt |
Data class published on status transition |
backend/src/main/kotlin/com/taskowolf/issues/domain/events/IssueFieldChangedEvent.kt |
Data class published per changed field; field is a plain string name |
backend/src/main/kotlin/com/taskowolf/issues/api/IssueController.kt |
REST endpoints; fetches IssueRefRepository on single-GET only |
backend/src/main/kotlin/com/taskowolf/issues/api/dto/CreateIssueRequest.kt |
title required; defaults type=TASK, priority=MEDIUM; optional assigneeId, parentId, storyPoints |
backend/src/main/kotlin/com/taskowolf/issues/api/dto/UpdateIssueRequest.kt |
All fields nullable; clearAssignee, clearDueDate, clearSprint, clearStoryPoints booleans used to explicitly null out fields; storyPoints accepts any Int? — no Fibonacci or range validation is enforced server-side |
backend/src/main/kotlin/com/taskowolf/issues/api/dto/IssueResponse.kt |
Serializes all issue fields; refs: List<IssueRefResponse> defaults to emptyList() on list endpoints |
backend/src/main/kotlin/com/taskowolf/issues/application/IssueService.kt |
create, update, findByProject, findByKey, delete, createTicketFromEmail; enforces project scope on all queries |
backend/src/main/kotlin/com/taskowolf/issues/infrastructure/IssueRepository.kt |
Custom queries: maxKeyNumberByProject, findOverdueByProjectId, findOverdueByProjectIdAndAssigneeId, sumStoryPointsBySprintId |
backend/src/main/kotlin/com/taskowolf/issues/infrastructure/IssueLinkRepository.kt |
Queries for IssueLink records by fromIssue or toIssue |
Extension Points¶
To add a new field to Issue:
- Add
@Column var newField: Ttobackend/src/main/kotlin/com/taskowolf/issues/domain/Issue.kt. - Add a Flyway column migration V23+ that alters
issuesto add the column. - Add the field to
IssueResponseinbackend/src/main/kotlin/com/taskowolf/issues/api/dto/IssueResponse.kt. - Add the field (nullable, with a clear boolean if needed) to
UpdateIssueRequest. - Handle the field in
IssueService.update()— follow the existing pattern: check old ≠ new, mutate, publishIssueFieldChangedEventif audit-worthy. - Add to
CreateIssueRequestif it should be settable on creation.
To add a new issue type:
Add the new value to IssueType.kt. No migration is needed — type is stored as VARCHAR with EnumType.STRING.
Common Pitfalls¶
- DO NOT query issues without scoping to a project. Always include
projectIdin repository queries.IssueServiceenforces this by callingprojectService.requireMember(projectKey, userId)before every operation. IssueResponse.refs[](issue links from theintegrationsmodule) is populated only on single-GET (GET /issues/{issueKey}), not on list endpoints. This is intentional for performance. Do not addrefspopulation to list paths.- DO NOT clear
slaStartTimeon DONE transitions from inside this module.slaStartTimeis managed by theservicedeskmodule. Trigger SLA stop viaIssueStatusChangedEventlisteners, not direct mutation. - When assigning an issue,
resolveAssignee()checks that the assignee is a member of the project. Passing a non-member's UUID asassigneeIdreturns 404 to avoid leaking user existence. - Parent issues are validated to belong to the same project. A parent from a different project throws
NotFoundException. - Status transitions are validated by
WorkflowService.validateTransition(). The new status must belong to the project's assigned workflow; a cross-workflow status throwsForbiddenException. - When
overdue=truein list queries, results are always ordered bydueDate ASCregardless of thesortparameter — the overdue JPQL query hardcodesORDER BY dueDate ASC. LabelRepositoryis injected in two cross-module locations.IssueController.get()injects it to callfindByIssueId(native SQL onissue_labels) for the single-issue GET — the list endpoint does not populate labels.IssueService.update()injects it to callfindAllByIdwhen resolving a new label set on PATCH. Both are deliberate exceptions to the no-cross-module-injection rule; see the labels module for full context.- Labels from a different project passed in
labelIdsare silently dropped, not rejected.IssueService.update()filters the resolved labels byit.project.id == project.idbefore assignment. The call returns 200 with only the valid labels applied — no error is raised for the invalid IDs.
Example¶
IssueFieldChangedEvent publish pattern in IssueService.update() — only publishes when the value actually changes:
Service update method — guard on value change before mutating and publishing:
// IssueService.kt — title field update
request.title?.let { newTitle ->
if (issue.title != newTitle) {
val old = issue.title
issue.title = newTitle
eventPublisher.publish(
IssueFieldChangedEvent(issue, currentUser, "title", old, newTitle)
)
}
}
Clearable fields use an explicit boolean flag rather than null-ambiguity:
// Same pattern for clearable fields (assignee, dueDate, sprint, storyPoints)
if (request.clearAssignee) {
val old = issue.assignee?.displayName
issue.assignee = null
if (old != null) {
eventPublisher.publish(
IssueFieldChangedEvent(issue, currentUser, "assignee", old, null)
)
}
}
Every field follows the same guard (old != new) to avoid spurious audit events on no-op updates.
Test Patterns¶
Unit tests (MockK, no Spring context)¶
| File | What is tested |
|---|---|
IssueServiceTest |
Key numbering: maxKeyNumberByProject result + 1 becomes the new keyNumber and is reflected in key |
IssueServiceTest |
EPIC type is set correctly when provided in CreateIssueRequest |
IssueServiceTest |
Missing workflow throws NotFoundException on create |
IssueServiceTest |
findByKey with a key from a different project throws NotFoundException |
IssueServiceTest |
Non-member assignee throws NotFoundException on create |
IssueServiceTest |
Parent from a different project throws NotFoundException on create |
IssueServiceTest |
update publishes IssueFieldChangedEvent with correct field, oldValue, newValue, actor when title changes |
IssueServiceTest |
update publishes IssueStatusChangedEvent with actor set when status changes |
IssueServiceTest |
overdue=true calls findOverdueByProjectId; overdue=true, assigneeMe=true calls findOverdueByProjectIdAndAssigneeId; neither calls general list query |
IssueServiceTest |
clearAssignee=true sets assignee to null; clearDueDate=true sets dueDate to null; clearSprint=true sets sprint to null |
IssueServiceTest |
storyPoints provided in UpdateIssueRequest sets storyPoints on the issue |
IssueServiceTest |
clearStoryPoints=true sets storyPoints to null and publishes IssueFieldChangedEvent with oldValue/newValue reflecting the change |
IssueServiceTest |
sprintId provided assigns sprint scoped to the project |
IssueServiceTest |
update sets labels when labelIds is a non-empty list |
IssueServiceTest |
update clears labels when labelIds is an empty list |
IssueServiceTest |
update silently drops labels from other projects |
Integration tests (Spring Boot Test + MockMvc + real DB, extend IntegrationTestBase)¶
| File | What is tested |
|---|---|
ProjectAndIssueIntegrationTest |
First issue key is {PREFIX}-1, second is {PREFIX}-2; statusCategory of new issue is TODO |