Module: labels¶
Purpose¶
Manages project-scoped colored labels. Labels can be assigned to issues in any quantity (many-to-many) and used to filter the issue list. Label CRUD is available from the dedicated settings page (LabelsPage) and also inline from the LabelSelector component on the issue detail view (when a search has no exact match, a "+ Create label" button creates the label on the fly).
Entities Owned¶
| Entity | Table | Key Fields |
|---|---|---|
Label |
labels |
name VARCHAR(50) NOT NULL, color VARCHAR(7) NOT NULL (hex), project FK→projects NOT NULL; UNIQUE (project_id, name) |
The issue_labels join table is owned by Issue.labels (@JoinTable on Issue), not by Label.
DB Schema¶
labels (V23)¶
CREATE TABLE labels (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL,
color VARCHAR(7) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (project_id, name)
);
issue_labels (V23)¶
Join table owned by Issue.labels. Cascade-deletes when either the issue or label is deleted.
| Column | Type | Constraint |
|---|---|---|
issue_id |
UUID | FK→issues ON DELETE CASCADE |
label_id |
UUID | FK→labels ON DELETE CASCADE |
Primary key: (issue_id, label_id).
API Endpoints¶
LabelController — /api/v1/projects/{key}/labels¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/projects/{key}/labels |
USER | Lists all labels for the project |
| POST | /api/v1/projects/{key}/labels |
USER | Creates a label; returns 201 Created; 409 if name already exists in project |
| PUT | /api/v1/projects/{key}/labels/{id} |
USER | Replaces name and color; 409 on name conflict |
| DELETE | /api/v1/projects/{key}/labels/{id} |
USER | Deletes label; returns 204 No Content; issue_labels rows removed by DB cascade |
All endpoints require project membership (ProjectService.requireMember()).
Events Emitted¶
None. Label assignment changes on issues are recorded as IssueFieldChangedEvent by IssueService.update() in the issues module.
Events Consumed¶
None.
Key Files¶
| File | Purpose |
|---|---|
backend/src/main/resources/db/migration/V23__labels.sql |
Creates labels and issue_labels tables |
backend/src/main/kotlin/com/taskowolf/labels/domain/Label.kt |
JPA entity |
backend/src/main/kotlin/com/taskowolf/labels/infrastructure/LabelRepository.kt |
findByProjectId, existsByProjectIdAndName, findByIssueId (native SQL) |
backend/src/main/kotlin/com/taskowolf/labels/application/LabelService.kt |
CRUD logic |
backend/src/main/kotlin/com/taskowolf/labels/api/LabelController.kt |
REST controller |
backend/src/main/kotlin/com/taskowolf/labels/api/dto/LabelRequest.kt |
{name, color} for POST/PUT |
backend/src/main/kotlin/com/taskowolf/labels/api/dto/LabelResponse.kt |
{id, name, color} |
backend/src/test/kotlin/com/taskowolf/labels/LabelServiceTest.kt |
Unit tests |
frontend/src/api/labels.ts |
labelsApi — list, create, update, delete HTTP calls |
Extension Points¶
The color palette (PALETTE) is defined as a constant in both frontend/src/components/issue/LabelSelector.tsx and frontend/src/pages/projects/settings/LabelsPage.tsx (duplicated). The backend accepts any valid hex string — the palette is only enforced client-side. If you change the palette, update both files.
Common Pitfalls¶
LabelRepositoryis injected in two cross-module locations.IssueController.get()injects it to callfindByIssueId(native SQL onissue_labels) for the single-issue GET.IssueService.update()injects it to callfindAllByIdwhen resolving a new label set on PATCH. Both are deliberate exceptions to the no-cross-module-injection rule. Do not add further cross-module injections.nullvs empty list onUpdateIssueRequest.labelIds.null= no change to labels;[]= remove all labels. The service checksrequest.labelIds != nullbefore touching the label set.- Labels from a different project passed in
labelIdsare silently dropped.IssueService.update()filters the resolved labels byit.project.id == project.idbefore assignment. No error is returned — the call succeeds and only the valid labels are applied. @ManyToMany(fetch=LAZY)onIssue.labels. Do not accessissue.labelsoutside a transaction.IssueController.get()fetches labels explicitly viaLabelRepository.findByIssueId()to avoid lazy-loading surprises.
Frontend Integration¶
| Concept | Detail |
|---|---|
| API client | frontend/src/api/labels.ts — labelsApi.list, create, update, delete |
| Hooks | useLabels, useCreateLabel, useUpdateLabel, useDeleteLabel (query key: ['labels', projectKey]) |
LabelChip |
Read-only colored pill; calls e.stopPropagation() in its onClick |
LabelSelector |
Dropdown for assigning labels to an issue; saves (issues PATCH labelIds) when the user clicks outside the dropdown, not on a submit button |
LabelsPage |
Settings page at /projects/{key}/settings/labels; full CRUD |
| Issue list filter | IssueListPage stores the active label filter in ?labelId=<UUID> search params; auto-clears the param if the label no longer exists (e.g. after deletion) |
Example¶
// Create a label then assign it to an issue
val label = labelService.create("WOLF", LabelRequest("bug", "#e11d48"), actor)
issueService.update("WOLF", issueId, UpdateIssueRequest(labelIds = listOf(label.id)), actor)
Test Patterns¶
| File | What is tested |
|---|---|
LabelServiceTest |
list returns labels for the correct project |
LabelServiceTest |
create saves a new label |
LabelServiceTest |
create throws ConflictException when name already exists in project |
LabelServiceTest |
update changes name and color |
LabelServiceTest |
update throws ConflictException when new name already exists in project |
LabelServiceTest |
delete removes the label |
LabelServiceTest |
delete throws NotFoundException when label belongs to a different 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 |