Skip to content

Developer Guide

This section covers the data model, configuration reference, required integration points, and extension points available to developers integrating Issue Tracker into an application.

Data Model

Issue Tracker persists a rich set of entities centred on five core concepts. An issue is the primary work item, belonging to a project and tracked through a configurable status workflow. A project is the organisational container for issues, members, and settings. A user holds the identity of anyone who can interact with the system. A journal records every change made to an issue, providing a full audit trail of comments and field edits. A tracker defines the type of an issue (e.g. Bug, Feature, Task) and controls which statuses are available.

Supporting entities cover time logging, file attachments, issue relationships, watchers, custom fields, role-based permissions, and saved queries.

---
config:
  layout: elk
---
erDiagram
    issues {
        integer id              PK
        integer tracker_id        FK
        integer project_id        FK
        varchar subject
        text    description
        integer status_id         FK
        integer priority_id       FK
        integer author_id         FK
        integer assigned_to_id    FK
        integer category_id       FK
        integer fixed_version_id  FK
        integer parent_id         FK
        date    start_date
        date    due_date
        timestamp closed_on
        boolean is_private
        integer done_ratio
        float   estimated_hours
        integer lft
        integer rgt
    }
    projects {
        integer id          PK
        varchar name
        varchar identifier
        boolean is_public
        integer parent_id   FK
        integer status
        timestamp created_on
    }
    users {
        integer id          PK
        varchar type
        varchar login
        varchar firstname
        varchar lastname
        integer status
        timestamp last_login_on
    }
    trackers {
        integer id                 PK
        varchar name
        integer default_status_id  FK
        boolean is_in_roadmap
    }
    versions {
        integer id              PK
        integer project_id      FK
        varchar name
        varchar status
        varchar sharing
        date    effective_date
    }
    issue_statuses {
        integer id       PK
        varchar name
        boolean is_closed
        integer position
    }
    journals {
        integer   id             PK
        integer   journalized_id FK
        integer   user_id        FK
        varchar   notes
        timestamp created_on
        boolean   private_notes
    }
    journal_details {
        integer id         PK
        integer journal_id FK
        varchar property
        varchar prop_key
        varchar old_value
        varchar value
    }
    attachments {
        integer id             PK
        integer container_id
        varchar container_type
        varchar filename
        varchar disk_filename
        bigint  filesize
        varchar digest
        integer author_id FK
    }
    issue_relations {
        integer id            PK
        integer issue_from_id FK
        integer issue_to_id   FK
        varchar relation_type
        integer delay
    }
    watchers {
        integer id             PK
        integer watchable_id   FK
        varchar watchable_type
        integer user_id        FK
    }
    time_entries {
        integer id          PK
        integer issue_id    FK
        integer project_id  FK
        integer user_id     FK
        integer activity_id FK
        float   hours
        date    spent_on
        varchar comments
    }
    enumerations {
        integer id         PK
        varchar name
        varchar type
        boolean is_default
        boolean active
        integer project_id FK
    }
    custom_fields {
        integer id           PK
        varchar name
        varchar type
        varchar field_format
        boolean is_required
    }
    custom_values {
        integer id               PK
        integer custom_field_id  FK
        integer customized_id
        varchar customized_type
        text    value
    }
    roles {
        integer id   PK
        varchar name
        text    permissions
    }

    issues         }o--||  projects       : "belongs to"
    issues         }o--||  trackers       : "typed by"
    issues         }o--||  issue_statuses : "has status"
    issues         }o--o|  users          : "assigned to"
    issues         }o--||  users          : "authored by"
    issues         }o--o|  issues         : "parent"
    issues         }o--o|  versions       : "target version"
    projects       ||--o{  versions       : "defines"
    issues         ||--o{  journals       : "has"
    issues         ||--o{  watchers       : "watched by"
    issues         ||--o{  time_entries   : "logged on"
    issues         ||--o{  issue_relations : "related to"
    issues         ||--o{  custom_values  : "has"
    journals       ||--o{  journal_details : "details"
    time_entries   }o--||  enumerations   : "activity"
    custom_fields  ||--o{  custom_values  : "values"
    projects       }o--o|  projects       : "parent"
    projects       ||--o{  time_entries   : "logged in"
Hold "Alt" / "Option" to enable pan & zoom

Database Schema

Issue Tracker creates thirty-three tables. The DDL below covers the core tables shown in the diagram above; the remainder are join tables (projects_trackers, custom_fields_projects, custom_fields_roles, custom_fields_trackers, roles_managed_roles, queries_roles, groups_users) and supporting tables (workflows, settings, enabled_modules, issue_categories, email_addresses, user_preferences, custom_field_enumerations).

Every primary key uses GenerationType.IDENTITY, so no sequences are created. Hibernate adds the foreign keys in a second pass after all tables exist, which is what allows the circular reference between projects and versions.

CREATE TABLE users (
    id                       INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    type                     VARCHAR(31)   NOT NULL,
    login                    VARCHAR(255)  NOT NULL,
    hashed_password          VARCHAR(40)   NOT NULL,
    salt                     VARCHAR(64),
    firstname                VARCHAR(255),
    lastname                 VARCHAR(255),
    mail_notification        VARCHAR(255)  NOT NULL,
    admin                    BOOLEAN,
    status                   INTEGER,
    language                 VARCHAR(5),
    must_change_passwd       BOOLEAN       NOT NULL,
    twofa_required           BOOLEAN,
    twofa_scheme             VARCHAR(255),
    twofa_totp_key           VARCHAR(255),
    twofa_totp_last_used_at  INTEGER,
    created_on               TIMESTAMP(6) WITH TIME ZONE,
    updated_on               TIMESTAMP(6) WITH TIME ZONE,
    last_login_on            TIMESTAMP(6) WITH TIME ZONE,
    passwd_changed_on        TIMESTAMP(6) WITH TIME ZONE,
    CONSTRAINT pk_users PRIMARY KEY (id)
);

CREATE TABLE roles (
    id                       INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    name                     VARCHAR(255)  NOT NULL,
    position                 INTEGER,
    assignable               BOOLEAN       NOT NULL,
    builtin                  INTEGER       NOT NULL,
    all_roles_managed        BOOLEAN       NOT NULL,
    issues_visibility        VARCHAR(30)   NOT NULL,
    users_visibility         VARCHAR(30)   NOT NULL,
    time_entries_visibility  VARCHAR(30)   NOT NULL,
    permissions              TEXT,
    settings                 TEXT,
    CONSTRAINT pk_roles PRIMARY KEY (id)
);

CREATE TABLE issue_statuses (
    id                 INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    name               VARCHAR(30)   NOT NULL,
    description        VARCHAR(255),
    is_closed          BOOLEAN,
    position           INTEGER,
    default_done_ratio INTEGER,
    CONSTRAINT pk_issue_statuses PRIMARY KEY (id)
);

CREATE TABLE trackers (
    id                 INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    name               VARCHAR(30)   NOT NULL,
    description        VARCHAR(255),
    default_status_id  INTEGER       NOT NULL,
    is_in_roadmap      BOOLEAN       NOT NULL,
    fields_bits        INTEGER,
    position           INTEGER,
    CONSTRAINT pk_trackers PRIMARY KEY (id),
    CONSTRAINT fk_trackers_status FOREIGN KEY (default_status_id) REFERENCES issue_statuses (id)
);

CREATE TABLE projects (
    id                      INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    name                    VARCHAR(255),
    identifier              VARCHAR(255),
    description             TEXT,
    homepage                VARCHAR(255),
    is_public               BOOLEAN,
    parent_id               INTEGER,
    status                  INTEGER,
    inherit_members         BOOLEAN,
    lft                     INTEGER,
    rgt                     INTEGER,
    default_assigned_to_id  INTEGER       UNIQUE,
    default_version_id      INTEGER       UNIQUE,
    default_issue_query_id  INTEGER       UNIQUE,
    created_on              TIMESTAMP(6),
    updated_on              TIMESTAMP(6),
    CONSTRAINT pk_projects PRIMARY KEY (id),
    CONSTRAINT fk_projects_parent FOREIGN KEY (parent_id) REFERENCES projects (id)
);

CREATE TABLE versions (
    id              INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    project_id      INTEGER       NOT NULL UNIQUE,
    name            VARCHAR(255)  NOT NULL,
    description     VARCHAR(255),
    status          VARCHAR(255),
    sharing         VARCHAR(255)  NOT NULL,
    effective_date  DATE,
    created_on      TIMESTAMP(6),
    updated_on      TIMESTAMP(6),
    CONSTRAINT pk_versions PRIMARY KEY (id),
    CONSTRAINT fk_versions_project FOREIGN KEY (project_id) REFERENCES projects (id)
);

CREATE TABLE enumerations (
    id             INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    name           VARCHAR(255),
    type           VARCHAR(255),
    position       INTEGER,
    position_name  VARCHAR(255),
    is_default     BOOLEAN,
    active         BOOLEAN,
    project_id     INTEGER,
    parent_id      INTEGER,
    CONSTRAINT pk_enumerations PRIMARY KEY (id)
);

CREATE TABLE members (
    id                 INTEGER  GENERATED BY DEFAULT AS IDENTITY,
    user_id            INTEGER  NOT NULL,
    project_id         INTEGER  NOT NULL,
    mail_notification  BOOLEAN  NOT NULL,
    created_on         TIMESTAMP(6) WITH TIME ZONE NOT NULL,
    CONSTRAINT pk_members PRIMARY KEY (id),
    CONSTRAINT fk_members_user    FOREIGN KEY (user_id)    REFERENCES users (id),
    CONSTRAINT fk_members_project FOREIGN KEY (project_id) REFERENCES projects (id)
);

CREATE TABLE member_roles (
    id              INTEGER  GENERATED BY DEFAULT AS IDENTITY,
    member_id       INTEGER  NOT NULL,
    role_id         INTEGER  NOT NULL,
    inherited_from  INTEGER,
    CONSTRAINT pk_member_roles PRIMARY KEY (id),
    CONSTRAINT fk_member_roles_member FOREIGN KEY (member_id) REFERENCES members (id),
    CONSTRAINT fk_member_roles_role   FOREIGN KEY (role_id)   REFERENCES roles (id)
);

CREATE TABLE issues (
    id                INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    subject           VARCHAR(255)  NOT NULL,
    description       TEXT,
    tracker_id        INTEGER       NOT NULL,
    project_id        INTEGER       NOT NULL,
    status_id         INTEGER       NOT NULL,
    priority_id       INTEGER       NOT NULL,
    author_id         INTEGER       NOT NULL,
    assigned_to_id    INTEGER,
    category_id       INTEGER,
    fixed_version_id  INTEGER,
    parent_id         INTEGER,
    root_id           INTEGER,
    lft               INTEGER,
    rgt               INTEGER,
    start_date        DATE,
    due_date          DATE,
    done_ratio        INTEGER,
    estimated_hours   FLOAT(53),
    is_private        BOOLEAN,
    lock_version      INTEGER,
    created_on        TIMESTAMP(6) WITH TIME ZONE,
    updated_on        TIMESTAMP(6) WITH TIME ZONE,
    closed_on         TIMESTAMP(6) WITH TIME ZONE,
    CONSTRAINT pk_issues PRIMARY KEY (id),
    CONSTRAINT fk_issues_tracker  FOREIGN KEY (tracker_id)       REFERENCES trackers (id),
    CONSTRAINT fk_issues_project  FOREIGN KEY (project_id)       REFERENCES projects (id),
    CONSTRAINT fk_issues_status   FOREIGN KEY (status_id)        REFERENCES issue_statuses (id),
    CONSTRAINT fk_issues_priority FOREIGN KEY (priority_id)      REFERENCES enumerations (id),
    CONSTRAINT fk_issues_author   FOREIGN KEY (author_id)        REFERENCES users (id),
    CONSTRAINT fk_issues_assignee FOREIGN KEY (assigned_to_id)   REFERENCES users (id),
    CONSTRAINT fk_issues_version  FOREIGN KEY (fixed_version_id) REFERENCES versions (id),
    CONSTRAINT fk_issues_parent   FOREIGN KEY (parent_id)        REFERENCES issues (id)
);

CREATE TABLE journals (
    id                INTEGER      GENERATED BY DEFAULT AS IDENTITY,
    journalized_id    INTEGER      NOT NULL,
    journalized_type  VARCHAR(30)  NOT NULL,
    user_id           INTEGER      NOT NULL,
    notes             TEXT,
    private_notes     BOOLEAN      NOT NULL,
    updated_by_id     INTEGER,
    created_on        TIMESTAMP(6) WITH TIME ZONE NOT NULL,
    updated_on        TIMESTAMP(6) WITH TIME ZONE,
    CONSTRAINT pk_journals PRIMARY KEY (id),
    CONSTRAINT fk_journals_issue FOREIGN KEY (journalized_id) REFERENCES issues (id)
);

CREATE TABLE journal_details (
    id          INTEGER      GENERATED BY DEFAULT AS IDENTITY,
    journal_id  INTEGER      NOT NULL,
    property    VARCHAR(30)  NOT NULL,
    prop_key    VARCHAR(30)  NOT NULL,
    old_value   TEXT,
    value       TEXT,
    CONSTRAINT pk_journal_details PRIMARY KEY (id),
    CONSTRAINT fk_journal_details_journal FOREIGN KEY (journal_id) REFERENCES journals (id)
);

CREATE TABLE attachments (
    id              INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    container_id    INTEGER,
    container_type  VARCHAR(255),
    filename        VARCHAR(255)  NOT NULL,
    disk_filename   VARCHAR(255)  NOT NULL,
    disk_directory  VARCHAR(255),
    content_type    VARCHAR(255),
    description     VARCHAR(255),
    filesize        BIGINT        NOT NULL,
    digest          VARCHAR(64)   NOT NULL,
    downloads       INTEGER,
    author_id       INTEGER       NOT NULL,
    created_on      TIMESTAMP(6) WITH TIME ZONE,
    CONSTRAINT pk_attachments PRIMARY KEY (id),
    CONSTRAINT fk_attachments_author FOREIGN KEY (author_id) REFERENCES users (id)
);

CREATE TABLE issue_relations (
    id             INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    issue_from_id  INTEGER       NOT NULL,
    issue_to_id    INTEGER       NOT NULL,
    relation_type  VARCHAR(255)  NOT NULL,
    delay          INTEGER,
    CONSTRAINT pk_issue_relations PRIMARY KEY (id),
    CONSTRAINT fk_issue_relations_from FOREIGN KEY (issue_from_id) REFERENCES issues (id),
    CONSTRAINT fk_issue_relations_to   FOREIGN KEY (issue_to_id)   REFERENCES issues (id)
);

CREATE TABLE watchers (
    id              INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    watchable_id    INTEGER       NOT NULL,
    watchable_type  VARCHAR(255)  NOT NULL,
    user_id         INTEGER,
    CONSTRAINT pk_watchers PRIMARY KEY (id),
    CONSTRAINT fk_watchers_issue FOREIGN KEY (watchable_id) REFERENCES issues (id),
    CONSTRAINT fk_watchers_user  FOREIGN KEY (user_id)      REFERENCES users (id)
);

CREATE TABLE time_entries (
    id           INTEGER        GENERATED BY DEFAULT AS IDENTITY,
    project_id   INTEGER        NOT NULL,
    issue_id     INTEGER,
    user_id      INTEGER        NOT NULL,
    author_id    INTEGER,
    activity_id  INTEGER        NOT NULL,
    hours        FLOAT(53)      NOT NULL,
    comments     VARCHAR(1024),
    spent_on     DATE           NOT NULL,
    tyear        INTEGER,
    tmonth       INTEGER,
    tweek        INTEGER,
    created_on   TIMESTAMP(6) WITH TIME ZONE NOT NULL,
    updated_on   TIMESTAMP(6) WITH TIME ZONE NOT NULL,
    CONSTRAINT pk_time_entries PRIMARY KEY (id),
    CONSTRAINT fk_time_entries_project  FOREIGN KEY (project_id)  REFERENCES projects (id),
    CONSTRAINT fk_time_entries_issue    FOREIGN KEY (issue_id)    REFERENCES issues (id),
    CONSTRAINT fk_time_entries_user     FOREIGN KEY (user_id)     REFERENCES users (id),
    CONSTRAINT fk_time_entries_activity FOREIGN KEY (activity_id) REFERENCES enumerations (id)
);

CREATE TABLE custom_fields (
    id               INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    name             VARCHAR(255)  NOT NULL,
    type             VARCHAR(255)  NOT NULL,
    field_format     VARCHAR(255)  NOT NULL,
    possible_values  VARCHAR(255)  NOT NULL,
    format_store     TEXT,
    regexp           TEXT,
    default_value    TEXT,
    description      TEXT,
    min_length       INTEGER,
    max_length       INTEGER,
    position         INTEGER,
    is_required      BOOLEAN       NOT NULL,
    is_for_all       BOOLEAN       NOT NULL,
    is_filter        BOOLEAN       NOT NULL,
    searchable       BOOLEAN,
    editable         BOOLEAN,
    visible          BOOLEAN,
    multiple         BOOLEAN,
    CONSTRAINT pk_custom_fields PRIMARY KEY (id)
);

CREATE TABLE custom_values (
    id               INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    custom_field_id  INTEGER       NOT NULL,
    customized_id    INTEGER       NOT NULL,
    customized_type  VARCHAR(255)  NOT NULL,
    value            TEXT,
    CONSTRAINT pk_custom_values PRIMARY KEY (id),
    CONSTRAINT fk_custom_values_field FOREIGN KEY (custom_field_id) REFERENCES custom_fields (id)
);

CREATE TABLE queries (
    id             INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    name           VARCHAR(255),
    type           VARCHAR(255),
    project_id     INTEGER       UNIQUE,
    user_id        INTEGER,
    visibility     INTEGER,
    filters        TEXT,
    column_names   TEXT,
    sort_criteria  TEXT,
    group_by       TEXT,
    options        TEXT,
    CONSTRAINT pk_queries PRIMARY KEY (id)
);

Users, Groups and the Special Principals

users uses single-table inheritance with a type discriminator, so it holds more than end-user accounts. The permitted values are User, Group, AnonymousUser, GroupAnonymous and GroupNonMember.

Groups are therefore rows in users, with their membership held in the groups_users join table. The same applies to the two special principals behind the built-in Anonymous and Non member roles. This is why a group can be added to a project through members exactly as a user can: both are users rows, and members.user_id does not care which.

Columns that only apply to real users — firstname, lastname, admin, status — are therefore nullable at the database level even though they are mandatory for the User discriminator.

Module Overview

Issue Tracker is structured as six Maven modules following the AppJars layered architecture:

Module Artifact ID Description
Model appjars-issue-tracker-model DTOs, enums, IssueTrackerAutoConfiguration
Business API appjars-issue-tracker-business Service interfaces including IssueService, ProjectService, UserService, JournalService, TimeEntryService, AttachmentService, and others
Business Impl appjars-issue-tracker-business-impl Service implementations with workflow, permission, and notification logic
Data API appjars-issue-tracker-data DAO interfaces
Data Impl appjars-issue-tracker-data-impl JPA entities and DAO implementations
Flow UI appjars-issue-tracker-flow 40+ Vaadin views, RouteConfigurer, dialogs and components

A monolithic application includes the three implementation modules (-business-impl, -data-impl, -flow). The API modules (-business, -data) are pulled in transitively.

Spring Auto-Configuration

Issue Tracker registers itself through Spring Boot's auto-configuration mechanism. The entry point is IssueTrackerAutoConfiguration, declared in:

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

This class scans the com.appjars.issuetracker package for all Spring components and JPA entities. No additional @EntityScan or @ComponentScan declarations are required in the host application beyond those described in the Getting Started guide.

Configuration Properties

Issue Tracker Properties

Property Default Description
appjars.issuetracker.auth.enabled true Whether Issue Tracker owns user authentication. Set to false when the host application manages credentials externally
appjars.issuetracker.authuser.error.fallback-route / Route the user is sent to when the authenticated principal has no matching Issue Tracker user
redmine.file.path files/ Directory in which uploaded attachments are stored

File Uploads

Property Default Description
spring.servlet.multipart.max-file-size 1MB Maximum size of individual attachment uploads
spring.servlet.multipart.max-request-size 10MB Maximum size of multipart requests

The Maximum attachment size configured in the Settings view applies on top of these limits. An upload must satisfy both, so the multipart limits should be at least as large as the configured maximum.

View URLs

Issue Tracker uses a segment-based route configuration. Properties in the com.appjars.issuetracker.url.views.* namespace each control one URL segment; full paths are formed by composing parent and child segments at startup. The root segment (default it) is shared by all routes. Selected properties and their resulting default paths:

Property Default segment Default full path
com.appjars.issuetracker.url.views.issues-list issues it/issues
com.appjars.issuetracker.url.views.projects-list projects it/projects
com.appjars.issuetracker.url.views.users-list users it/users
com.appjars.issuetracker.url.views.time-entries time_entries it/time_entries
com.appjars.issuetracker.url.views.settings settings it/settings
com.appjars.issuetracker.url.views.roles-list roles it/roles
com.appjars.issuetracker.url.views.workflows-edit workflows it/workflows
com.appjars.issuetracker.url.views.custom-fields custom_fields it/custom_fields

See Configuring View Route Paths for the complete segment property table and an explanation of how child routes are derived from parent segments.

Required Integration: LoggedInUsernameProvider

Issue Tracker requires an implementation of LoggedInUsernameProvider to identify the currently authenticated user. This is used throughout the module to resolve the logged-in user for issue authorship, permission checks, journal entries, and time logging.

@Primary
@Component
public class AppLoggedInUsernameProvider implements LoggedInUsernameProvider {

    @Override
    public Optional<String> getLoggedInUsername() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated()) {
            return Optional.empty();
        }
        return Optional.of(auth.getName());
    }
}

The @Primary annotation is required to override any default implementation provided by the module. Without it, Spring will raise an ambiguity error.

Administration Access

Issue Tracker guards its administration routes itself. AdminAccessGuard is registered by RouteConfigurer as a global BeforeEnterListener, and it rejects navigation to any administration view by a user whose Issue Tracker account is not flagged as an administrator. Rejected navigation is forwarded to the application root and an access-denied notification is shown.

The guarded views are the enumeration, issue status, tracker, role, role permission, user, group, workflow, custom field and settings views. Everything else — issues, projects, time entries, gantt, versions, search, activity, my page, my account, viewing a user profile and creating a project — remains open to any authenticated user, because access to those is decided by the project-level permission model instead.

This runs independently of the host application's own security configuration, so an administration URL cannot be reached by typing it directly even if the host has not restricted the route.

Authentication Ownership

By default Issue Tracker owns user authentication: it stores password hashes, exposes the login and password fields on the user form, and offers the New user button in the user list.

Applications that authenticate users elsewhere — an existing identity provider, or the User Manager AppJar — should set:

appjars.issuetracker.auth.enabled=false

With authentication disabled, the authentication section of the user form is hidden, the login field becomes read-only, and user creation through the UI is turned off. Issue Tracker still needs a user record for every principal it sees, so the host application becomes responsible for provisioning them.

When an authenticated principal has no matching Issue Tracker user, navigation fails with a UserNotFoundException. UserNotFoundRoutingErrorHandler logs the unprovisioned username and the route that was attempted, renders an error page, and offers a way back to the route configured in appjars.issuetracker.authuser.error.fallback-route.

Licensing and Free Mode

Issue Tracker is licensed per application through the standard AppJars LicenseChecker. See Licensing for how a license file is installed and how to confirm at startup which mode an application is running in. Without a valid license the module runs in free mode: every feature remains functional, but three limits are enforced.

Limit Value
Users 5
Open issues 5
Time entries logged per day 10

The limits are enforced in the service layer — UserServiceImpl.save, IssueServiceImpl.save and TimeEntryServiceImpl.save raise FreeLimitReachedException once the corresponding count is reached — so they cannot be bypassed by calling the services directly.

The views enforce the same limits ahead of time, disabling the creation buttons and showing a restrictions bar explaining which limit was hit. Exceeding a limit does not lock existing data: when a count is above the limit the affected views become read-only until the count is brought back under it, and the message says so.

A full license removes all three limits and nothing else changes.

Issue Lifecycle

An issue moves through statuses defined per tracker. The available transitions from any given status are controlled by the active workflow, which is configured per tracker and role combination. The WorkflowTransitionService determines which statuses are reachable by the current user based on their role and the issue's current status.

Issue statuses carry an is_closed flag. When a status with this flag is applied, the issue's closed_on timestamp is recorded. Issues in closed statuses are excluded from active work queues by default.

Done ratio (done_ratio) tracks completion percentage. It can be set manually or derived from the default ratio defined on the target status.

Journal and Audit Trail

Every change to an issue produces a JournalEntity record linking the issue to the user who made the change and the timestamp. Each journal entry contains:

  • Notes — a free-text comment added by the user at the time of the change.
  • Details — one JournalDetailsEntity row per changed field, recording the property name, old value, and new value.

Private notes (private_notes = true) are visible only to users with the appropriate role permission. The updated_by_id and updated_on columns track subsequent edits to the note text after initial creation.

JournalService.updateHistory() compares two snapshots of an issue (before and after a change) and writes the resulting detail records automatically. Application code does not need to compute diffs manually.

Attachments

AttachmentEntity uses a polymorphic container pattern: the container_type and container_id columns identify what the file is attached to (an issue, a time entry, or a project). The disk_filename column holds the name under which the file is stored on disk, which may differ from the original filename to avoid collisions. A SHA-256 digest is computed on upload for integrity verification.

AttachmentService.save(dto, inputStream) handles digest computation, metadata persistence, and file storage in a single call. AttachmentService.openFile(dto) returns a File handle for download or processing.

Issue Relations

Issues can be linked to each other with one of nine typed relationships:

Type Meaning
RELATES General association
DUPLICATES / DUPLICATED One issue is a duplicate of another
BLOCKS / BLOCKED One issue blocks progress on another
PRECEDES / FOLLOWS Scheduling dependency with optional delay in days
COPIED_TO / COPIED_FROM One issue was copied from another

IssueRelationService.circularDependency() detects cycles before saving a relation. blocks() and wouldReschedule() provide programmatic access to dependency queries.

Subtasks

Issues support a parent-child hierarchy through the parent_id and root_id self-referential foreign keys. The lft and rgt columns implement a nested set model, enabling efficient tree queries without recursive SQL.

IssueService.fillChildTree() populates the full subtask tree for a given issue. IssueService.findLeaves() returns the terminal (childless) descendants.

Custom Fields

Custom fields extend the data model for issues, time entries, and projects without schema changes. Each CustomFieldEntity defines the field name, format type, and validation rules. The supported format types are: BOOLEAN, DATE, FILE, FLOAT, INTEGER, KEY_VALUE_LIST, LINK, LIST, LONG_TEXT, TEXT, USER, and VERSION.

Format-specific options (possible values, min and max length, regular expression, allowed extensions, display style) are held in a YAML-encoded format_store column, parsed with SnakeYAML behind the CustomFieldFormatStore accessor rather than being read directly. Changing a field's format preserves its role, tracker and project associations. When a field's display option is absent or malformed, it falls back to a drop-down.

Custom values are stored in custom_values using a polymorphic association (customized_type + customized_id). Visibility is controlled per role and per project; CustomFieldService.filterByUserVisibility() returns only the fields the current user may see.

When saving or updating an issue or time entry, pass the custom field values as a Map<CustomFieldDto, String> to the appropriate service method:

Map<CustomFieldDto, String> customValues = new HashMap<>();
customValues.put(categoryField, "Backend");
customValues.put(priorityField, "High");
issueService.save(issueDto, attachments, customValues);

Time Tracking

TimeEntryEntity records hours spent against an issue or directly against a project. Each entry references an activity type drawn from the enumerations table (type Activity). Activity types can be defined globally or overridden per project.

TimeEntryService exposes reporting methods that aggregate hours by period and by configurable grouping criteria:

// Aggregate spent time for a report period
Map<List<String>, Map<String, Double>> report = timeEntryService.getSpentTimeReport(
    ReportPeriodOptions.THIS_MONTH,
    filter,
    projectFilter,
    Set.of(SpentTimeReportOption.USER, SpentTimeReportOption.ACTIVITY)
);

Permission Model

Access to Issue Tracker features is governed by a role-based permission system. Each project member is assigned one or more RoleEntity records, each carrying a set of RolePermission flags. There are over eighty distinct permissions covering issue operations, project management, time tracking, file management, and more.

PermissionService.hasPermission() is the central access check:

boolean canEditIssues = permissionService.hasPermission(
    userId, projectId, RolePermission.EDIT_ISSUES
);

Role visibility settings (issues_visibility, users_visibility, time_entries_visibility) further restrict what records a role can see within the scope of its permissions.

Service API

IssueService

// Save a new issue with attachments and custom field values
Integer save(IssueDto issue, Collection<AttachmentDto> attachments,
             Map<CustomFieldDto, String> customValues);

// Update an existing issue, recording changes in a journal entry
void update(IssueDto issue, Collection<AttachmentDto> attachments,
            Map<CustomFieldDto, String> customValues, JournalDto journal);

// Populate the full subtask tree for an issue
void fillChildTree(IssueDto issue);

// Return all leaf (childless) descendants of an issue
List<IssueDto> findLeaves(IssueDto issue);

// Retrieve a filtered, paginated stream of issues within a project
Stream<IssueDto> listByFilter(int offset, int limit, IssueFilter filter, ProjectDto project);

// Count issues matching a filter
Long countByFilter(IssueFilter filter, ProjectDto project);

// Return a status-per-tracker pivot summary for a project
List<IssueTrackerStatusSummaryDto> getTrackerStatusPivotForProject(Integer projectId);

// Return the total estimated hours for a project
Double getEstimatedTimeTotal(Integer projectId);

ProjectService

// Return top-level projects (no parent)
List<ProjectDto> findFirstLevelProjects();

// Find a project by its URL identifier
Optional<ProjectDto> findByIdentifier(String identifier);

// Return projects the logged-in user has access to
List<ProjectDto> getProjectsAvailableForLoggedInUser();

// Return all projects visible to a given user
List<ProjectDto> findVisibleProjectForUser(UserDto user);

// Return the activity stream for a project within a filter range
List<ActivityDto> findProjectActivity(ProjectActivityFilter filter);

JournalService

// Compare before/after snapshots of an issue and persist change detail records
void updateHistory(IssueDto oldIssue, IssueDto updatedIssue);

TimeEntryService

// Return total hours logged against an issue
double getTotalHoursByIssueId(Integer issueId);

// Save a time entry with custom field values
Integer save(TimeEntryDto entry, Map<CustomFieldDto, String> customValues);

// Return total hours matching a filter
Double getHoursSumByFilter(TimeEntryFilter filter, ProjectDto project);

// Generate a spent-time report grouped by the specified criteria
Map<List<String>, Map<String, Double>> getSpentTimeReport(ReportPeriodOptions period,
    TimeEntryFilter filter, ProjectDto project, Set<SpentTimeReportOption> criteria);

WorkflowTransitionService

// Return all statuses reachable from a given status for a tracker and role set
List<IssueStatusDto> getAvailableStatuses(Integer trackerId, Set<Integer> roleIds,
    UserDto loggedInUser, Integer currentStatusId);

// Copy a workflow definition from one tracker/role to others
void copyWorkflow(TrackerDto sourceTracker, RoleDto sourceRole,
    Set<TrackerDto> targetTrackers, Set<RoleDto> targetRoles);

Customisation

Assigning a Router Layout

By default, Issue Tracker registers its views without a parent layout. To wrap them in the application's main layout, inject the RouteConfigurer bean using its qualifier and call setViewsRouterLayout in a @PostConstruct method:

@Autowired
@Qualifier("IssueTrackerRouteConfigurer")
private RouteConfigurer routeConfigurer;

@PostConstruct
public void configure() {
    routeConfigurer.setViewsRouterLayout(MainLayout.class);
}

The @Qualifier is required because multiple AppJars may contribute a bean named RouteConfigurer.

Customising View URLs

Issue Tracker view paths are configured through segment properties. Each property controls one URL segment that may be shared by multiple routes. To relocate the issue and project routes under a custom prefix:

com.appjars.issuetracker.url.views.issues-list=tracker/issues
com.appjars.issuetracker.url.views.projects-list=tracker/projects
com.appjars.issuetracker.url.views.time-entries=tracker/time_entries

The full list of segment properties and an explanation of how child routes are derived is in Configuring View Route Paths.