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"
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
JournalDetailsEntityrow 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.