Skip to content

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

  • LabelRepository is injected in two cross-module locations. IssueController.get() injects it to call findByIssueId (native SQL on issue_labels) for the single-issue GET. IssueService.update() injects it to call findAllById when 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.
  • null vs empty list on UpdateIssueRequest.labelIds. null = no change to labels; [] = remove all labels. The service checks request.labelIds != null before touching the label set.
  • Labels from a different project passed in labelIds are silently dropped. IssueService.update() filters the resolved labels by it.project.id == project.id before assignment. No error is returned — the call succeeds and only the valid labels are applied.
  • @ManyToMany(fetch=LAZY) on Issue.labels. Do not access issue.labels outside a transaction. IssueController.get() fetches labels explicitly via LabelRepository.findByIssueId() to avoid lazy-loading surprises.

Frontend Integration

Concept Detail
API client frontend/src/api/labels.tslabelsApi.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