Developer Guide: Adding New Features to TQPRO¶
Overview¶
This guide provides a step-by-step approach to adding new features to the TQPRO system, based on the Trip Offer Management feature implementation. It covers the complete development lifecycle from database design to frontend implementation.
Table of Contents¶
- Architecture Overview
- Prerequisites
- Step 1: Database Design
- Step 2: Create Database Entity Classes
- Step 3: Create Canonical Entity Classes
- Step 4: Create Business Logic Facade
- Step 5: Create REST API Endpoints
- Step 6: Configure NTS Service Plugin (NEW)
- Step 7: Configure Entity Mappings
- Step 8: Create Frontend HTML Page
- Step 9: Create JavaScript Module
- Step 10: Testing
- Step 11: Documentation
- Best Practices
- Common Patterns
Architecture Overview¶
TQPRO follows a layered architecture:
┌─────────────────────────────────────────┐
│ Frontend Layer │
│ HTML + JavaScript + Foundation CSS │
└─────────────────────────────────────────┘
↓ REST API
┌─────────────────────────────────────────┐
│ API Layer │
│ REST Endpoints (TripOfferApi) │
│ - Parameter extraction │
│ - Exception handling │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Facade Layer │
│ Business Logic (TripOfferFacade) │
│ - Validation │
│ - Business rules │
│ - Transaction management │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Canonical Entity Layer │
│ Platform-independent entities │
│ (CTripPage, CTripSnippet) │
└─────────────────────────────────────────┘
↓ EntityFacade + Config
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ Native Entity Layer │ │ Plugin management layer │
│ Database-specific entities │ -> │ (integration to external systems │
│ (TrippageEntity, TripsnippetEntity) │ │ Payment GW, Ticketing, Airline GDS │
└─────────────────────────────────────────┘ └─────────────────────────────────────────┘
↓ Hibernate/JPA
┌─────────────────────────────────────────┐
│ Database Layer │
│ PostgreSQL (nts schema) │
└─────────────────────────────────────────┘
Prerequisites¶
Before starting, ensure you have:
- ✅ Access to the TQPRO codebase
- ✅ PostgreSQL database access
- ✅ Java Development Kit (JDK) 17+
- ✅ Gradle build tool
- ✅ Understanding of:
- Java, JPA/Hibernate
- REST APIs
- JavaScript (ES6+)
- HTML/CSS (Foundation framework)
Step 1: Database Design¶
1.1 Design Your Schema¶
Create tables in the nts schema. Consider:
- Primary keys (use sequences)
- Foreign keys for relationships
- Indexes for performance
- Column constraints
Example: Trip Offer Management Tables
-- Main entity table
CREATE TABLE IF NOT EXISTS nts.trip_page (
page_id INTEGER NOT NULL,
page_name VARCHAR(200) NOT NULL,
page_desc VARCHAR(1000) NOT NULL,
CONSTRAINT trip_page_pk PRIMARY KEY(page_id)
);
-- Detail entity table with foreign key
CREATE TABLE IF NOT EXISTS nts.trip_snippet (
snippet_id INTEGER NOT NULL,
page_id INTEGER NOT NULL,
code VARCHAR(20),
title VARCHAR(50),
description VARCHAR(1000),
active INTEGER,
CONSTRAINT trip_snippet_code_uindex UNIQUE(code),
CONSTRAINT trip_snippet_pk PRIMARY KEY(snippet_id),
CONSTRAINT trip_snippet_trip_page_page_id_fk FOREIGN KEY (page_id)
REFERENCES nts.trip_page(page_id)
);
1.2 Create SQL Script¶
Location: doc/your_feature_schema.sql
Include: - Table creation statements - Sequence creation (if not managed by Hibernate) - Sample data for testing - Comments for documentation
-- Create sequences
CREATE SEQUENCE IF NOT EXISTS nts.trip_page_seq START 1;
CREATE SEQUENCE IF NOT EXISTS nts.trip_snippet_seq START 1;
-- Add comments
COMMENT ON TABLE nts.trip_page IS 'Stores destination pages';
COMMENT ON COLUMN nts.trip_page.page_id IS 'Unique identifier (sequence-managed)';
1.3 Apply to Database¶
Step 2: Create Database Entity Classes¶
Database entities map directly to database tables using Hibernate/JPA.
2.1 Create Package Structure¶
Location: tqapp/src/main/java/com/perun/tlinq/client/nts/db/[feature_name]/
2.2 Create Entity Class¶
Naming Convention: [TableName]Entity.java (e.g., TrippageEntity.java)
Template:
package com.perun.tlinq.client.nts.db.trip;
import com.perun.tlinq.client.nts.entity.NTSEntity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
/**
* Hibernate entity for nts.trip_page table
*/
@Entity
@Table(name = "trip_page", schema = "nts")
public class TrippageEntity extends NTSEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "trip_page_id_gen")
@SequenceGenerator(name = "trip_page_id_gen", sequenceName = "nts.trip_page_seq", allocationSize = 1)
@Column(name = "page_id", nullable = false)
private Integer pageId;
@Size(max = 200)
@NotNull
@Column(name = "page_name", nullable = false, length = 200)
private String pageName;
@Size(max = 1000)
@NotNull
@Column(name = "page_desc", nullable = false, length = 1000)
private String pageDesc;
// Must override getId() from NTSEntity
@Override
public Integer getId() {
return pageId;
}
public void setId(Integer pageId) {
this.pageId = pageId;
}
// Getters and setters for all fields
public Integer getPageId() {
return pageId;
}
public void setPageId(Integer pageId) {
this.pageId = pageId;
}
public String getPageName() {
return pageName;
}
public void setPageName(String pageName) {
this.pageName = pageName;
}
public String getPageDesc() {
return pageDesc;
}
public void setPageDesc(String pageDesc) {
this.pageDesc = pageDesc;
}
}
2.3 Key Points¶
✅ Extend NTSEntity: All NTS database entities must extend NTSEntity
✅ Annotations:
- @Entity - Marks as JPA entity
- @Table - Specifies table name and schema
- @Id - Primary key field
- @GeneratedValue - Auto-generated values
- @SequenceGenerator - Sequence configuration
- @Column - Column mapping
- @Size, @NotNull - Validation constraints
✅ Naming: Database column names (lowercase with underscores) ✅ Override getId(): Required by NTSEntity
Step 3: Create Canonical Entity Classes¶
Canonical entities are platform-independent business objects.
3.1 Create Package Structure¶
Location: tqapp/src/main/java/com/perun/tlinq/entity/[feature_name]/
3.2 Create Canonical Class¶
Naming Convention: C[EntityName].java (e.g., CTripPage.java)
Template:
package com.perun.tlinq.entity.trip;
import com.perun.tlinq.entity.TlinqEntity;
import java.io.Serializable;
/**
* Canonical entity for trip destination pages
*/
public class CTripPage extends TlinqEntity implements Serializable {
private Integer pageId;
private String pageName;
private String pageDesc;
// Getters and setters
public Integer getPageId() {
return pageId;
}
public void setPageId(Integer pageId) {
this.pageId = pageId;
}
public String getPageName() {
return pageName;
}
public void setPageName(String pageName) {
this.pageName = pageName;
}
public String getPageDesc() {
return pageDesc;
}
public void setPageDesc(String pageDesc) {
this.pageDesc = pageDesc;
}
@Override
public String toString() {
return "CTripPage{" +
"pageId=" + pageId +
", pageName='" + pageName + '\'' +
", pageDesc='" + pageDesc + '\'' +
'}';
}
}
3.3 Key Points¶
✅ Extend TlinqEntity: All canonical entities extend TlinqEntity
✅ Implement Serializable: Required for caching and serialization
✅ No Annotations: Plain Java objects (POJOs)
✅ Business Names: Use camelCase, business-friendly names
✅ toString(): Override for debugging
Step 4: Create Business Logic Facade¶
The facade centralizes all business logic for your feature.
4.1 Create Facade Class¶
Location: tqapp/src/main/java/com/perun/tlinq/entity/[feature_name]/[Feature]Facade.java
Template:
package com.perun.tlinq.entity.trip;
import com.perun.tlinq.client.nts.service.NTSServiceFactory;
import com.perun.tlinq.config.SelectCriteriaList;
import com.perun.tlinq.config.ServiceFactoryConfig;
import com.perun.tlinq.entity.EntityFacade;
import com.perun.tlinq.entity.TlinqEntity;
import com.perun.tlinq.util.ClientConfig;
import com.perun.tlinq.util.TlinqClientException;
import com.perun.tlinq.util.TlinqErr;
import com.perun.tlinq.util.TypeUtil;
import java.util.*;
import java.util.logging.Logger;
/**
* Facade class for managing trip offers
*/
public class TripOfferFacade extends EntityFacade {
private static final String FACTORY = NTSServiceFactory.FACTORY_NAME;
private static volatile String sysSession;
static {
try {
ServiceFactoryConfig sf = ClientConfig.instance().getDefaultFactory();
sysSession = sf.getPropertyList().getProperty("system.session");
} catch (Exception ex) {
Logger.getLogger(TripOfferFacade.class.getName()).severe("Cannot get default system session ID!");
}
}
public TripOfferFacade(String token) {
super(TypeUtil.isEmptyString(token) ? sysSession : token);
}
@Override
public Object write(TlinqEntity entity) throws TlinqClientException {
entity.setFactoryName(FACTORY);
return super.write(entity);
}
// ==================== CRUD OPERATIONS ====================
/**
* List all pages with optional pagination
*/
@SuppressWarnings("unchecked")
public List<CTripPage> listPages(Integer page, Integer pageSize) throws TlinqClientException {
SelectCriteriaList criteria = new SelectCriteriaList();
List<CTripPage> pages = (List<CTripPage>) search(FACTORY, "TripPage", criteria);
if (pages == null) {
return Collections.emptyList();
}
// Apply pagination if requested
if (page != null && pageSize != null) {
int start = page * pageSize;
int end = Math.min(start + pageSize, pages.size());
if (start < pages.size()) {
return pages.subList(start, end);
} else {
return Collections.emptyList();
}
}
return pages;
}
/**
* Read a single page by ID
*/
@SuppressWarnings("unchecked")
public CTripPage readPage(Integer pageId) throws TlinqClientException {
if (pageId == null) {
throw new TlinqClientException(TlinqErr.MISSING_PARAMETER, "Page ID is required");
}
List<CTripPage> results = (List<CTripPage>) read(FACTORY, "TripPage", pageId);
if (results != null && !results.isEmpty()) {
return results.get(0);
} else {
throw new TlinqClientException(TlinqErr.ENTITY_NOT_FOUND, "Page not found");
}
}
/**
* Create or update a page
*/
public CTripPage writePage(CTripPage page) throws TlinqClientException {
// Validate
if (page == null) {
throw new TlinqClientException(TlinqErr.MISSING_PARAMETER, "Page entity is required");
}
if (TypeUtil.isEmptyString(page.getPageName())) {
throw new TlinqClientException(TlinqErr.MISSING_PARAMETER, "Page name is required");
}
// Business logic here
return (CTripPage) write(page);
}
/**
* Delete a page
*/
public void deletePage(Integer pageId) throws TlinqClientException {
if (pageId == null) {
throw new TlinqClientException(TlinqErr.MISSING_PARAMETER, "Page ID is required");
}
deleteEntity(FACTORY, "TripPage", pageId);
}
// Add more methods as needed...
}
4.2 Key Points¶
✅ Extend EntityFacade: Provides base CRUD operations ✅ System Session: Initialize default session for background tasks ✅ Override write(): Set factory name for entities ✅ Validation: Always validate inputs ✅ Type Safety: Return specific canonical types ✅ Error Handling: Use TlinqClientException with appropriate error codes ✅ Documentation: JavaDoc for all public methods
4.3 Common Methods Pattern¶
// List with criteria
@SuppressWarnings("unchecked")
public List<CEntity> listEntities(SelectCriteriaList criteria) {
List<CEntity> results = (List<CEntity>) search(FACTORY, "EntityName", criteria);
return (results == null ? Collections.emptyList() : results);
}
// Read by ID
@SuppressWarnings("unchecked")
public CEntity readEntity(Integer id) {
List<CEntity> results = (List<CEntity>) read(FACTORY, "EntityName", id);
if (results != null && !results.isEmpty()) {
return results.get(0);
}
throw new TlinqClientException(TlinqErr.ENTITY_NOT_FOUND, "Entity not found");
}
// Write (create/update)
public CEntity writeEntity(CEntity entity) {
// Validation
// Business logic
return (CEntity) write(entity);
}
// Delete
public void deleteEntity(Integer id) {
deleteEntity(FACTORY, "EntityName", id);
}
Step 5: Create REST API Endpoints¶
The API layer is a thin wrapper that delegates to the facade.
5.1 Create API Class¶
Location: tqapi/src/main/java/com/perun/tlinq/api/[Feature]Api.java
Template:
package com.perun.tlinq.api;
import com.perun.tlinq.api.entity.TlinqApiResponse;
import com.perun.tlinq.entity.trip.*;
import com.perun.tlinq.util.TlinqClientException;
import com.perun.tlinq.util.TlinqErr;
import com.perun.tlinq.util.TypeUtil;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.*;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* REST API for Trip Offer Management
*/
@Path("/tripoffer")
public class TripOfferApi {
private static final Logger logger = Logger.getLogger(TripOfferApi.class.getName());
// ==================== ENDPOINT METHODS ====================
@POST
@Path("/page/list")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response listPages(Map reqData, @Context HttpHeaders headers) {
Response resp;
TlinqApiResponse ar;
String session = null;
try {
// Extract session from request or header
session = ApiUtil.gmp(reqData, "session", String.class, false);
if(TypeUtil.isEmptyString(session)) {
session = headers.getHeaderString("X-Auth-Request-Access-Token");
reqData.put("session", session);
}
logger.log(Level.INFO, "BEGIN APICALL listPages for {0}",
TypeUtil.isEmptyString(session) ? "NULL" : session);
// Delegate to implementation method
ar = doListPages(reqData);
} catch (TlinqClientException e) {
ar = new TlinqApiResponse(e.getErrorCode(), e.getMessage());
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL,
ex.getMessage() == null ? ex.getClass().getName() : ex.getMessage());
}
logger.log(Level.INFO, "END APICALL listPages for {0}",
TypeUtil.isEmptyString(session) ? "NULL" : session);
resp = Response.ok(ar).build();
return resp;
}
@POST
@Path("/page/write")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response writePage(Map reqData) {
Response resp;
TlinqApiResponse ar;
String session = null;
try {
session = ApiUtil.gmp(reqData, "session", String.class, false);
logger.log(Level.INFO, "BEGIN APICALL writePage for {0}",
TypeUtil.isEmptyString(session) ? "NULL" : session);
ar = doWritePage(reqData);
} catch (TlinqClientException e) {
ar = new TlinqApiResponse(e.getErrorCode(), e.getMessage());
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL,
ex.getMessage() == null ? ex.getClass().getName() : ex.getMessage());
}
logger.log(Level.INFO, "END APICALL writePage for {0}",
TypeUtil.isEmptyString(session) ? "NULL" : session);
resp = Response.ok(ar).build();
return resp;
}
// ==================== IMPLEMENTATION METHODS ====================
private TlinqApiResponse doListPages(Map reqData) throws TlinqClientException {
// Extract parameters
String session = ApiUtil.gmp(reqData, "session", String.class, false);
Integer page = ApiUtil.gmp(reqData, "page", Integer.class, false);
Integer pageSize = ApiUtil.gmp(reqData, "pageSize", Integer.class, false);
// Call facade
TripOfferFacade facade = new TripOfferFacade(session);
List<CTripPage> results = facade.listPages(page, pageSize);
// Return response
return new TlinqApiResponse(results);
}
private TlinqApiResponse doWritePage(Map reqData) throws TlinqClientException {
// Extract session and remove from data
String session = ApiUtil.gmp(reqData, "session", String.class, false);
reqData.remove("session");
// Convert JSON to entity
String jsonStr = TypeUtil.extractJsonFromMap(reqData);
CTripPage page = TypeUtil.extractFromJson(jsonStr, CTripPage.class);
// Call facade
TripOfferFacade facade = new TripOfferFacade(session);
CTripPage savedPage = facade.writePage(page);
// Return response
return new TlinqApiResponse(savedPage);
}
}
5.2 Key Points¶
✅ Path Annotation: Use @Path to define base URL
✅ HTTP Method: Use @POST (TQPRO standard for all operations)
✅ Consumes/Produces: Always JSON
✅ Exception Handling: Catch both TlinqClientException and general exceptions
✅ Logging: Log begin/end of API calls
✅ Authentication: The API layer supports a three-tier auth pipeline (JWT Bearer → proxy headers → guest session). See API Specification for details.
✅ Session Management: Extract from request body or header
✅ Delegation: Endpoint methods delegate to doXXX methods
✅ Parameter Extraction: Use ApiUtil.gmp() helper
5.3 URL Naming Convention¶
/api/[feature]/[entity]/[action]
Examples:
/api/tripoffer/page/list
/api/tripoffer/page/write
/api/tripoffer/page/read
/api/tripoffer/page/delete
/api/tripoffer/snippet/list
Step 6: Configure NTS Service Plugin¶
The NTS Service Plugin configuration (nts-client.xml) maps service names to their implementation classes, methods, and entities. This is a critical step that is separate from entity mappings and must be completed for all CRUD operations to work.
6.1 Understanding the Configuration¶
File: config/nts-client.xml
This file defines how the NTSServiceFactory executes service calls. Each service entry maps:
- A service name (referenced in tourlinq-config.xml)
- To an implementation class
- With a specific method and entity type
<NTSPluginConfig>
<PluginProperties>
<property name="dbname" value="lab"/>
<property name="weekstart" value="SUN"/>
</PluginProperties>
<Services>
<!-- Service definitions here -->
</Services>
</NTSPluginConfig>
6.2 Standard Service Types¶
There are four standard service classes for basic CRUD operations:
| Service Class | Purpose | Method Attribute |
|---|---|---|
NTSEntityReadService |
Read by ID, Search | Empty (uses default) |
NTSEntityWriteService |
Create, Update | Empty (uses default) |
NTSEntityDeleteService |
Delete by ID | Empty (uses default) |
NTSCustomService |
Custom business logic | Specific method name |
6.3 Add Service Configurations¶
For each entity, add service entries for read, write, and delete operations:
<!-- Read/Search Service -->
<Service name="readTripPage"
class="com.perun.tlinq.client.nts.service.NTSEntityReadService"
method=""
entity="com.perun.tlinq.client.nts.db.trip.TrippageEntity"
idField="id"/>
<!-- Write (Create/Update) Service -->
<Service name="saveTripPage"
class="com.perun.tlinq.client.nts.service.NTSEntityWriteService"
method=""
entity="com.perun.tlinq.client.nts.db.trip.TrippageEntity"
idField="id"/>
<!-- Delete Service -->
<Service name="deleteTripPage"
class="com.perun.tlinq.client.nts.service.NTSEntityDeleteService"
method=""
entity="com.perun.tlinq.client.nts.db.trip.TrippageEntity"
idField="id"/>
6.4 Service Attributes Reference¶
| Attribute | Description | Example |
|---|---|---|
name |
Service name (must match tourlinq-config.xml) | saveTripPage |
class |
Fully qualified service implementation class | com.perun.tlinq.client.nts.service.NTSEntityWriteService |
method |
Method to invoke (empty for standard services) | "" or "customMethod" |
entity |
Native entity class (JPA entity) | com.perun.tlinq.client.nts.db.trip.TrippageEntity |
idField |
Primary key field name in native entity | id |
6.5 Custom Service Configuration¶
For operations beyond basic CRUD (complex searches, calculations, bulk operations), use custom services:
<!-- Custom service with specific method -->
<Service name="searchTripOffers"
class="com.perun.tlinq.client.nts.service.trip.NTSTripService"
method="searchOffers"
entity="com.perun.tlinq.client.nts.entity.trip.NTSTripSearchResult"
idField=""/>
<!-- Custom service for tree/hierarchical data -->
<Service name="getTripTree"
class="com.perun.tlinq.client.nts.service.trip.NTSTripService"
method="getTripTree"
entity="com.perun.tlinq.client.nts.entity.trip.NTSTripTreeNode"
idField=""/>
6.6 Create Custom Service Class¶
When using custom services, create the service implementation class:
Location: tqapp/src/main/java/com/perun/tlinq/client/nts/service/[feature]/NTS[Feature]Service.java
package com.perun.tlinq.client.nts.service.trip;
import com.perun.tlinq.client.nts.service.NTSCustomService;
import com.perun.tlinq.client.nts.entity.trip.*;
import jakarta.persistence.EntityManager;
import java.util.*;
/**
* Custom NTS service for Trip operations
*/
public class NTSTripService extends NTSCustomService {
/**
* Custom search method
* @param params Map of search parameters from the request
* @return List of search results
*/
public List<NTSTripSearchResult> searchOffers(Map<String, Object> params) {
EntityManager em = getEntityManager();
// Extract parameters
String destination = (String) params.get("destination");
Integer minDuration = (Integer) params.get("minDuration");
// Build and execute query
// ...
return results;
}
/**
* Get hierarchical tree data
*/
public List<NTSTripTreeNode> getTripTree(Map<String, Object> params) {
boolean includeInactive = params.containsKey("includeInactive")
&& (Boolean) params.get("includeInactive");
// Build tree structure
// ...
return tree;
}
}
6.7 Create NTS Native Entity Classes (for Custom Services)¶
Custom services often return data structures different from JPA entities. Create NTS native entity classes:
Location: tqapp/src/main/java/com/perun/tlinq/client/nts/entity/[feature]/
package com.perun.tlinq.client.nts.entity.trip;
import com.perun.tlinq.entity.RemoteEntityI;
import java.util.List;
/**
* NTS native entity for trip search results
*/
public class NTSTripSearchResult implements RemoteEntityI {
private Integer tripId;
private String tripName;
private String destination;
private Integer duration;
private Double lowestPrice;
// Getters and setters
public Integer getTripId() { return tripId; }
public void setTripId(Integer tripId) { this.tripId = tripId; }
public String getTripName() { return tripName; }
public void setTripName(String tripName) { this.tripName = tripName; }
// ... other getters/setters
}
/**
* NTS native entity for hierarchical tree node
*/
public class NTSTripTreeNode implements RemoteEntityI {
private Integer nodeId;
private String nodeName;
private String nodeType; // "category", "destination", "trip"
private List<NTSTripTreeNode> children;
// Getters and setters
}
6.8 Key Points¶
✅ Service names must match: The name attribute in nts-client.xml must exactly match the service names in tourlinq-config.xml
✅ Delete services are required: Without a delete service configuration, deleteEntity() calls in the facade will fail
✅ idField for standard services: Use the JPA entity's ID field name (usually id)
✅ idField for custom services: Can be empty if the service doesn't operate on a single entity by ID
✅ Custom service classes: Must extend NTSCustomService and implement methods matching the method attribute
✅ NTS native entities: Must implement RemoteEntityI interface
6.9 Naming Conventions¶
| Component | Convention | Example |
|---|---|---|
| Read service | read[EntityName] |
readTripPage |
| Write service | save[EntityName] |
saveTripPage |
| Delete service | delete[EntityName] |
deleteTripPage |
| List service | list[EntityName]s |
listTripPages |
| Custom service class | NTS[Feature]Service |
NTSTripService |
| NTS native entity | NTS[EntityName] |
NTSTripSearchResult |
6.10 Complete Example¶
For a Trip Offer feature with standard CRUD plus custom search:
<!-- nts-client.xml additions -->
<!-- Trip Page - Standard CRUD -->
<Service name="readTripPage"
class="com.perun.tlinq.client.nts.service.NTSEntityReadService"
method=""
entity="com.perun.tlinq.client.nts.db.trip.TrippageEntity"
idField="id"/>
<Service name="saveTripPage"
class="com.perun.tlinq.client.nts.service.NTSEntityWriteService"
method=""
entity="com.perun.tlinq.client.nts.db.trip.TrippageEntity"
idField="id"/>
<Service name="deleteTripPage"
class="com.perun.tlinq.client.nts.service.NTSEntityDeleteService"
method=""
entity="com.perun.tlinq.client.nts.db.trip.TrippageEntity"
idField="id"/>
<!-- Trip Snippet - Standard CRUD -->
<Service name="readTripSnippet"
class="com.perun.tlinq.client.nts.service.NTSEntityReadService"
method=""
entity="com.perun.tlinq.client.nts.db.trip.TripsnippetEntity"
idField="id"/>
<Service name="saveTripSnippet"
class="com.perun.tlinq.client.nts.service.NTSEntityWriteService"
method=""
entity="com.perun.tlinq.client.nts.db.trip.TripsnippetEntity"
idField="id"/>
<Service name="deleteTripSnippet"
class="com.perun.tlinq.client.nts.service.NTSEntityDeleteService"
method=""
entity="com.perun.tlinq.client.nts.db.trip.TripsnippetEntity"
idField="id"/>
<!-- Custom Services -->
<Service name="searchTripOffers"
class="com.perun.tlinq.client.nts.service.trip.NTSTripService"
method="searchOffers"
entity="com.perun.tlinq.client.nts.entity.trip.NTSTripSearchResult"
idField=""/>
Step 7: Configure Entity Mappings¶
Entity mappings define how canonical entities map to native entities.
7.1 Locate Configuration File¶
File: config/tourlinq-config.xml
7.2 Add Entity Mapping¶
Find an appropriate location (e.g., after related entities) and add:
<!-- Trip Offer Management Entities -->
<Entity name="TripPage"
class="com.perun.tlinq.entity.trip.CTripPage"
idField="pageId"
defaultFactory="NTSServiceFactory">
<EntityFactoryList>
<Factory name="NTSServiceFactory"
nativeEntity="com.perun.tlinq.client.nts.db.trip.TrippageEntity">
<ServiceList>
<Service name="saveTripPage" action="create"/>
<Service name="saveTripPage" action="update"/>
<Service name="readTripPage" action="read"/>
<Service name="readTripPage" action="search"/>
<Service name="deleteTripPage" action="delete"/>
</ServiceList>
<FieldMappingList>
<FieldMapping targetField="pageId" sourceField="pageId" mapping="DirectMapping"/>
<FieldMapping targetField="pageName" sourceField="pageName" mapping="DirectMapping"/>
<FieldMapping targetField="pageDesc" sourceField="pageDesc" mapping="DirectMapping"/>
</FieldMappingList>
</Factory>
</EntityFactoryList>
</Entity>
<Entity name="TripSnippet"
class="com.perun.tlinq.entity.trip.CTripSnippet"
idField="snippetId"
defaultFactory="NTSServiceFactory">
<EntityFactoryList>
<Factory name="NTSServiceFactory"
nativeEntity="com.perun.tlinq.client.nts.db.trip.TripsnippetEntity">
<ServiceList>
<Service name="saveTripSnippet" action="create"/>
<Service name="saveTripSnippet" action="update"/>
<Service name="readTripSnippet" action="read"/>
<Service name="readTripSnippet" action="search"/>
<Service name="deleteTripSnippet" action="delete"/>
</ServiceList>
<FieldMappingList>
<FieldMapping targetField="snippetId" sourceField="snippetId" mapping="DirectMapping"/>
<FieldMapping targetField="pageId" sourceField="pageId" mapping="DirectMapping"/>
<FieldMapping targetField="code" sourceField="code" mapping="DirectMapping"/>
<FieldMapping targetField="title" sourceField="title" mapping="DirectMapping"/>
<FieldMapping targetField="description" sourceField="description" mapping="DirectMapping"/>
<FieldMapping targetField="active" sourceField="active" mapping="DirectMapping"/>
<!-- Add all other fields -->
</FieldMappingList>
</Factory>
</EntityFactoryList>
</Entity>
7.3 Key Elements¶
✅ Entity Attributes:
- name: Entity name used in facade methods
- class: Fully qualified canonical class name
- idField: Primary key field name (canonical)
- defaultFactory: Service factory to use
✅ Factory Attributes:
- name: Factory name (usually NTSServiceFactory)
- nativeEntity: Fully qualified DB entity class name
✅ Service Actions:
- create: Insert new record
- update: Update existing record
- read: Read by ID
- search: Search with criteria
- delete: Delete by ID
✅ Field Mapping Types:
- DirectMapping: Simple field copy
- ArrayMapping: Transform arrays
- IndexMapping: Pick element from array
- NestedMapping: Load related entity
- ModelLookup: Search for parent entity
- NoMapping: Skip field
7.4 Advanced Mapping Example¶
<!-- Foreign key with lookup -->
<FieldMapping targetField="partnerName" sourceField="partnerId"
mapping="ModelLookup" reverseMapping="NoMapping">
<ModelLookupMapping>
<ParentEntity name="Customer" idField="customerId" valueField="fullName"/>
</ModelLookupMapping>
</FieldMapping>
<!-- Array mapping -->
<FieldMapping targetField="items" targetFieldEntity="Item"
sourceField="itemList" mapping="ArrayMapping"/>
<!-- Index mapping (get first element) -->
<FieldMapping targetField="primaryContact" sourceField="contacts" mapping="IndexMapping">
<IndexMapping index="0"/>
</FieldMapping>
Step 8: Create Frontend HTML Page¶
Frontend pages use Foundation CSS framework and follow established design patterns.
8.1 Create HTML File¶
Location: tqweb-adm/[feature]man.html
Template Structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Feature Management</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Foundation CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/foundation-sites@6.8.1/dist/css/foundation.min.css">
<link rel="stylesheet" href="css/foundation-datepicker.css">
<link rel="stylesheet" href="css/foundation-icons.css">
<link rel="stylesheet" href="css/tqapp.css">
<link href="css/fontawesome.min.css" rel="stylesheet">
<link href="css/solid.css" rel="stylesheet">
<style>
/* Design system variables */
:root {
--primary-color: #362c5d;
--primary-dark: #2a2149;
--secondary-color: #FFC166;
--success-color: #3adb76;
--warning-color: #ffae00;
--alert-color: #cc4b37;
--light-bg: #f8f9fa;
--border-color: #e1e4e8;
--shadow-sm: 0 2px 4px rgba(0,0,0,0.1);
--transition: all 0.3s ease;
}
body {
background-color: var(--light-bg);
font-family: 'Inter', 'Noto Sans', sans-serif;
}
/* Content cards */
.content-card {
background: white;
border-radius: 8px;
box-shadow: var(--shadow-sm);
margin-bottom: 20px;
overflow: hidden;
}
.content-card-header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.content-card-title {
font-size: 1.25rem;
font-weight: 600;
color: #2d3748;
margin: 0;
}
/* Add more styles as needed */
</style>
</head>
<body>
<!-- Include jQuery and Foundation -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/foundation-sites@6.8.1/dist/js/foundation.min.js"></script>
<script src="js/vendor/notify.js"></script>
<!-- Header template -->
<header id="pgheader" data-load-template="header.html"></header>
<!-- Modal Dialogs -->
<section role="dialog">
<div class="small reveal" id="edit_dlg" data-reveal data-close-on-click="false">
<div class="content-card-header">
<h4 class="content-card-title"><i class="fi-page"></i> Edit Item</h4>
</div>
<div class="content-card-body">
<div class="grid-x grid-padding-x">
<input type="hidden" id="item_id" data-entity-name="itemRecord" data-entity-field="itemId">
<div class="cell small-12">
<label for="item_name">Name
<input type="text" id="item_name"
data-entity-name="itemRecord"
data-entity-field="itemName">
</label>
</div>
</div>
</div>
<div class="grid-x grid-padding-x" style="padding: 15px;">
<div class="cell small-6">
<button class="button success expanded" id="item_save" data-close>
<i class="fi-check"></i> Save
</button>
</div>
<div class="cell small-6">
<button class="button secondary expanded" data-close>
<i class="fi-x"></i> Cancel
</button>
</div>
</div>
</div>
</section>
<!-- Main Content -->
<div class="grid-container">
<div class="grid-x grid-margin-x">
<!-- Master table (left side) -->
<div class="cell small-12 medium-4">
<div class="content-card">
<div class="content-card-header">
<h4 class="content-card-title">Items</h4>
<button class="button success" id="item_add">
<i class="fi-plus"></i> New
</button>
</div>
<div style="max-height: 500px; overflow-y: auto;">
<table class="hover" id="items_table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="items_tbody">
</tbody>
</table>
</div>
</div>
</div>
<!-- Detail table (right side) -->
<div class="cell small-12 medium-8">
<div class="content-card">
<div class="content-card-header">
<h4 class="content-card-title">Details</h4>
</div>
<!-- Detail content here -->
</div>
</div>
</div>
</div>
<!-- Load page utilities -->
<script src="js/pageutil.js"></script>
<script>
loadPageTemplates();
$(document).foundation();
</script>
<!-- Load module -->
<script type="module">
import * as $page from "./js/modules/featureman.js";
import * as $site from "./js/modules/globals.js";
$().ready(function() {
$site.setGlobalHandlers({ requireAuth: true });
// Event handlers
$('#item_add').on('click', $page.newItem);
$('#item_save').on('click', $page.saveItem);
$('#items_tbody').on('click', 'tr', $page.selectItem);
$('#items_tbody').on('click', '.item-edit', $page.editItem);
$('#items_tbody').on('click', '.item-delete', $page.deleteItem);
// Initialize
$page.initialize();
});
</script>
</body>
</html>
8.2 Design Patterns¶
✅ Master-Detail Layout: Left panel (1/3) for master, right panel (2/3) for detail
✅ Content Cards: Use .content-card for sections
✅ Foundation Grid: Use .grid-x, .grid-padding-x, .cell
✅ Modal Dialogs: Use .reveal for add/edit dialogs
✅ Data Binding: Use data-entity-name and data-entity-field attributes
✅ Icons: Use Foundation icons (fi-*) or Font Awesome
✅ Colors: Use CSS variables from design system
8.3 Key Components¶
Buttons:
<button class="button primary">Primary Action</button>
<button class="button success">Create</button>
<button class="button alert">Delete</button>
<button class="button secondary">Cancel</button>
Tables:
<table class="hover">
<thead>
<tr><th>Column</th></tr>
</thead>
<tbody id="table_body">
<!-- Populated by JavaScript -->
</tbody>
</table>
Forms with Data Binding:
Step 9: Create JavaScript Module¶
JavaScript modules handle all client-side logic.
9.1 Create Module File¶
Location: tqweb-adm/js/modules/[feature]man.js
Template:
/*
* Copyright (c) 2024. Perun Consulting Services FZ LLE
* Feature Management Module
*/
import * as $util from "./utilmod.js";
// Module state
export let currentPage = 0;
export let selectedItemId = null;
export const pageSize = 10;
let items = [];
let itemRecord = {};
/**
* Initialize the module
*/
export function initialize() {
loadItems(0);
// Setup entity data bindings
$util.setUpEntityData("itemRecord", itemRecord);
}
// ==================== DATA LOADING ====================
/**
* Load items with pagination
*/
export function loadItems(page) {
currentPage = page;
const reqData = {
session: "",
page: page,
pageSize: pageSize
};
$.ajax({
url: '/api/feature/item/list',
type: 'POST',
data: JSON.stringify(reqData),
contentType: 'application/json',
success: function(response) {
if (response.success && response.data) {
items = response.data;
renderItems();
} else {
$.notify(response.message || 'Failed to load items', 'error');
}
},
error: function(xhr, status, error) {
$.notify('Error loading items: ' + error, 'error');
}
});
}
/**
* Render items table
*/
function renderItems() {
const tbody = $('#items_tbody');
tbody.empty();
if (items.length === 0) {
tbody.append('<tr><td colspan="3" style="text-align: center;">No items found</td></tr>');
return;
}
items.forEach(item => {
const row = $('<tr>').attr('data-item-id', item.itemId);
if (selectedItemId === item.itemId) {
row.addClass('selected-row');
}
row.append($('<td>').text(item.itemId));
row.append($('<td>').text(item.itemName));
const actionsCell = $('<td>');
actionsCell.append(`<button class="button tiny primary item-edit" data-id="${item.itemId}">
<i class="fi-pencil"></i>
</button>`);
actionsCell.append(`<button class="button tiny alert item-delete" data-id="${item.itemId}">
<i class="fi-x"></i>
</button>`);
row.append(actionsCell);
tbody.append(row);
});
}
// ==================== CRUD OPERATIONS ====================
/**
* Select an item (click handler)
*/
export function selectItem(e) {
// Ignore if clicking on action buttons
if ($(e.target).closest('.button').length > 0) {
return;
}
const row = $(e.currentTarget);
const itemId = row.data('item-id');
selectedItemId = itemId;
// Update UI
$('#items_tbody tr').removeClass('selected-row');
row.addClass('selected-row');
// Load details for this item
// loadDetails(itemId);
}
/**
* Open new item dialog
*/
export function newItem() {
itemRecord = {};
$util.setUpEntityData("itemRecord", itemRecord);
$('#item_id').val('');
$('#item_name').val('');
$('#edit_dlg').foundation('open');
}
/**
* Open edit item dialog
*/
export function editItem(e) {
e.stopPropagation();
const itemId = $(e.currentTarget).data('id');
const item = items.find(i => i.itemId === itemId);
if (!item) {
$.notify('Item not found', 'error');
return;
}
itemRecord = {...item};
$util.setUpEntityData("itemRecord", itemRecord);
$('#item_id').val(item.itemId || '');
$('#item_name').val(item.itemName || '');
$('#edit_dlg').foundation('open');
}
/**
* Save item
*/
export function saveItem() {
$util.extractEntityData("itemRecord", itemRecord);
// Validate
if (!itemRecord.itemName) {
$.notify('Please enter an item name', 'warning');
return;
}
const reqData = {
session: "",
...itemRecord
};
$.ajax({
url: '/api/feature/item/write',
type: 'POST',
data: JSON.stringify(reqData),
contentType: 'application/json',
success: function(response) {
if (response.success) {
$.notify('Item saved successfully', 'success');
loadItems(currentPage);
} else {
$.notify(response.message || 'Failed to save item', 'error');
}
},
error: function(xhr, status, error) {
$.notify('Error saving item: ' + error, 'error');
}
});
}
/**
* Delete item
*/
export function deleteItem(e) {
e.stopPropagation();
const itemId = $(e.currentTarget).data('id');
if (!confirm('Are you sure you want to delete this item?')) {
return;
}
const reqData = {
session: "",
itemId: itemId
};
$.ajax({
url: '/api/feature/item/delete',
type: 'POST',
data: JSON.stringify(reqData),
contentType: 'application/json',
success: function(response) {
if (response.success) {
$.notify('Item deleted successfully', 'success');
if (selectedItemId === itemId) {
selectedItemId = null;
}
loadItems(currentPage);
} else {
$.notify(response.message || 'Failed to delete item', 'error');
}
},
error: function(xhr, status, error) {
$.notify('Error deleting item: ' + error, 'error');
}
});
}
9.2 Key Points¶
✅ ES6 Modules: Use import/export
✅ State Management: Module-level variables for state
✅ Data Binding: Use $util.setUpEntityData() and $util.extractEntityData()
✅ API Calls: Prefer tlinq() from globals.js over raw $.ajax(). The tlinq() function handles authentication (JWT Bearer token for OIDC, session token for guests) and returns a Promise resolving to apiData.
✅ Error Handling: Always handle success and error cases
✅ User Feedback: Use $.notify() for notifications
✅ Event Delegation: Use event handlers on parent elements
9.3 Common Patterns¶
AJAX Request Pattern:
$.ajax({
url: '/api/feature/endpoint',
type: 'POST',
data: JSON.stringify({
session: "",
...params
}),
contentType: 'application/json',
success: function(response) {
if (response.success && response.data) {
// Handle success
} else {
$.notify(response.message || 'Operation failed', 'error');
}
},
error: function(xhr, status, error) {
$.notify('Error: ' + error, 'error');
}
});
Data Binding Pattern:
// Setup (before showing dialog)
$util.setUpEntityData("recordName", recordObject);
// Extract (after editing)
$util.extractEntityData("recordName", recordObject);
Step 10: Testing¶
10.1 Unit Testing¶
Create tests for facade methods:
@Test
public void testWritePage() throws TlinqClientException {
TripOfferFacade facade = new TripOfferFacade(testSession);
CTripPage page = new CTripPage();
page.setPageName("test-page.html");
page.setPageDesc("Test page");
CTripPage saved = facade.writePage(page);
assertNotNull(saved.getPageId());
assertEquals("test-page.html", saved.getPageName());
}
10.2 Integration Testing¶
Test API endpoints:
curl -X POST http://localhost:8080/api/tripoffer/page/list \
-H "Content-Type: application/json" \
-d '{"session":"test-session","page":0,"pageSize":10}'
10.3 Frontend Testing¶
- Open browser developer tools
- Navigate to your page
- Test each operation (add, edit, delete)
- Check console for errors
- Verify API calls in Network tab
Step 11: Documentation¶
11.1 Create Feature Documentation¶
Location: doc/[FEATURE]_MANAGEMENT.md
Include: - Feature overview - Architecture diagram - Database schema - API endpoints - Usage instructions - Configuration details
11.2 Update Main Documentation¶
Update relevant docs: - README.md (if needed) - API documentation - Configuration guide
Best Practices¶
Code Quality¶
✅ Naming Conventions:
- DB entities: [TableName]Entity (e.g., TrippageEntity)
- Canonical entities: C[EntityName] (e.g., CTripPage)
- Facade: [Feature]Facade (e.g., TripOfferFacade)
- API: [Feature]Api (e.g., TripOfferApi)
- HTML: [feature]man.html (e.g., offerman.html)
- JS Module: [feature]man.js (e.g., offerman.js)
✅ Error Handling: - Always validate inputs in facade - Use appropriate TlinqErr codes - Provide meaningful error messages - Log errors appropriately
✅ Thread Safety (TQ-52):
- Declare shared static fields as volatile (e.g., private static volatile String sysSession)
- Use ConcurrentHashMap instead of HashMap for shared caches
- Always close Hibernate sessions in finally blocks to prevent leaks
- Use try/finally pattern around beginTransaction() with rollback() in catch/finally
- Use ThreadLocal<DateFormat> instead of shared SimpleDateFormat instances
✅ Security:
- Validate all user inputs
- Use session tokens or JWT Bearer tokens (see API Specification for auth pipeline)
- Sanitize data before database operations
- Implement proper authorization via api-roles.properties
- Use parameterized HQL queries to prevent SQL injection
✅ Performance: - Use pagination for large datasets - Consider caching where appropriate - Optimize database queries - Minimize API calls from frontend
Code Organization¶
✅ Package Structure:
tqapp/
src/main/java/com/perun/tlinq/
client/nts/db/[feature]/ # DB entities
entity/[feature]/ # Canonical entities & facade
tqapi/
src/main/java/com/perun/tlinq/api/ # API classes
tqweb-adm/
[feature]man.html # HTML page
js/modules/[feature]man.js # JS module
config/
tourlinq-config.xml # Entity mappings
doc/
[feature]_schema.sql # Database schema
[FEATURE]_MANAGEMENT.md # Feature docs
✅ Commit Messages: - Use clear, descriptive messages - Reference issue numbers - Explain the "why" not just the "what"
Common Patterns¶
Master-Detail Pattern¶
Use Case: Managing related entities (e.g., orders and order items)
Implementation: - Master table on left (1/3 width) - Detail table on right (2/3 width) - Selecting master row loads related details - Both tables support CRUD operations
Example: Trip pages (master) with snippets (detail)
Wizard Pattern¶
Use Case: Multi-step processes (e.g., visa application)
Implementation: - Progress stepper at top - Step content in cards - Next/Previous buttons - Validation at each step
Example: Visa application workflow
Search and Filter Pattern¶
Use Case: Finding records in large datasets
Implementation: - Search panel at top - Filter chips for quick filters - Paginated results table - Sort capabilities
Example: Group management search
Troubleshooting¶
Common Issues¶
Problem: Entity not found error - Solution: Check entity name in config matches facade calls exactly
Problem: Field mapping not working - Solution: Verify field names match between canonical and DB entities
Problem: API returns 404
- Solution: Check @Path annotations and ensure API is deployed
Problem: JavaScript not loading - Solution: Check module path and ensure type="module" in script tag
Problem: Session errors - Solution: Verify session token is being passed correctly
Checklist¶
Before considering your feature complete:
- [ ] Database schema created and applied
- [ ] DB entity classes created with JPA annotations
- [ ] Canonical entity classes created
- [ ] Facade created with all CRUD operations
- [ ] API created with all endpoints
- [ ] NTS services configured in nts-client.xml (including delete services)
- [ ] Entity mappings added to tourlinq-config.xml
- [ ] HTML page created following design patterns
- [ ] JavaScript module created with all handlers
- [ ] Feature tested end-to-end
- [ ] Documentation created
- [ ] Code reviewed
- [ ] Committed with clear messages
- [ ] Pushed to feature branch
Additional Resources¶
- TQCOMMON Documentation:
docs/tqcommon/TQCOMMON_EXECUTIVE_SUMMARY.md - Refactoring Guide:
doc/TRIP_OFFER_REFACTORING.md - Trip Offer Example:
doc/TRIP_OFFER_MANAGEMENT.md - Foundation CSS Docs: https://get.foundation/sites/docs/
- JPA Documentation: https://jakarta.ee/specifications/persistence/
Example: Complete File List¶
For reference, here's the complete file list for the Trip Offer Management feature:
Database:
- doc/trip_offer_schema.sql
Backend:
- tqapp/src/main/java/com/perun/tlinq/client/nts/db/trip/TrippageEntity.java
- tqapp/src/main/java/com/perun/tlinq/client/nts/db/trip/TripsnippetEntity.java
- tqapp/src/main/java/com/perun/tlinq/client/nts/db/trip/TripskeletonEntity.java
- tqapp/src/main/java/com/perun/tlinq/client/nts/db/trip/TripsnippetfileEntity.java
- tqapp/src/main/java/com/perun/tlinq/entity/trip/CTripPage.java
- tqapp/src/main/java/com/perun/tlinq/entity/trip/CTripSnippet.java
- tqapp/src/main/java/com/perun/tlinq/entity/trip/CTripSkeleton.java
- tqapp/src/main/java/com/perun/tlinq/entity/trip/CTripSnippetFile.java
- tqapp/src/main/java/com/perun/tlinq/entity/trip/TripOfferFacade.java
- tqapi/src/main/java/com/perun/tlinq/api/TripOfferApi.java
Configuration:
- config/tourlinq-config.xml (entity mappings added)
Frontend:
- tqweb-adm/offerman.html
- tqweb-adm/js/modules/offerman.js
Documentation:
- doc/TRIP_OFFER_MANAGEMENT.md
- doc/TRIP_OFFER_REFACTORING.md
Summary¶
This guide provides a comprehensive workflow for adding new features to TQPRO. Key takeaways:
- Follow the layered architecture: Database → Native Entities → Canonical Entities → Facade → API → Frontend
- Use established patterns: Look at existing features for reference
- Configuration-driven: Entity mappings in XML provide flexibility
- Consistent naming: Follow conventions throughout
- Test thoroughly: Unit, integration, and frontend testing
- Document everything: Help future developers understand your work
By following this guide, you ensure consistency with the TQPRO architecture and create maintainable, high-quality features.
Document Version: 1.2 Last Updated: 2026-02-19 Based on: Trip Offer Management Feature Implementation
Revision History:
- v1.2 (2026-02-19): Added OIDC authentication notes (TQ-51), thread safety best practices (TQ-52), updated frontend init pattern with requireAuth, prefer tlinq() over $.ajax()
- v1.1 (2025-12-17): Added Step 6 "Configure NTS Service Plugin" covering nts-client.xml configuration, delete services, custom services, and NTS native entity classes
- v1.0 (2025-11-12): Initial version