Skip to content

Tiqets Product API - Complete Implementation Plan

Overview

Implement complete response handling for the Tiqets "Search and Filter Products" API, updating database structure, persistence entities, and expanding the canonical model (CProduct) with additional fields. Nested objects require their own canonical classes, database tables, and proper XML mappings.


Architecture Summary

API Response (JSON)
TiqetsProduct.java (Remote DTO with nested classes)
ProductEntity.java + Nested Entity Tables (JPA/DB)
TqProduct.java + TqNested*.java (Native entities)
    ↓ (XML Field Mappings)
CProduct.java + CNested*.java (Canonical entities)
REST API Response

Part 1: Simple Fields (Direct to CProduct)

Fields to Add to CProduct

Pricing (6 fields): - retailPrice - BigDecimal - Retail price in requested currency - priceSupplierCurrency - BigDecimal - Price in supplier's currency - supplierCurrencyCode - String - Supplier currency code - prediscountPrice - BigDecimal - Original price before discount - discountPercentage - BigDecimal - Discount percentage - bookingFee - BigDecimal - Booking fee amount

Content (4 fields): - contentLanguage - String - Content language code - tagline - String - Short tagline - promoLabel - String - Promotional label - summary - String - Summary description

Feature Flags (7 fields): - supportsCancellationInsurance - Boolean - instantTicketDelivery - Boolean - wheelchairAccess - Boolean - smartphoneTicket - Boolean - isPackage - Boolean - inPackageIds - String - Comma-separated package IDs - packageProductIds - String - Comma-separated product IDs in package

Language Support (4 fields): - supportedLanguages - String - Comma-separated language codes - liveGuideLanguages - String - Comma-separated - audioGuideLanguages - String - Comma-separated - languageSelectionOptions - String - Comma-separated

Time/Duration (5 fields): - startingTime - String - HH:MM:SS format - productTimezone - String - Timezone identifier - durationFormatted - String - HH:MM:SS format - advanceArrivalTime - String - HH:MM:SS - lastAdmissionWindow - String - HH:MM:SS

Ratings (2 fields): - ratingAverage - BigDecimal - 0-5 rating - ratingCount - Integer - Number of reviews

Sale Status (3 fields): - saleStatus - String - "available" or "unavailable" - saleStatusReason - String - Reason for unavailability - saleStatusExpectedReopen - Date - Expected reopen date

URLs (2 fields): - productUrl - String - URL to product on tiqets.com - productCheckoutUrl - String - URL to checkout on tiqets.com

Content Extended (3 fields): - productSlug - String - URL slug - whatsIncluded - String - Localized list of inclusions - whatsExcluded - String - Localized list of exclusions

Tags (1 field): - tagIds - String - Comma-separated tag IDs


Part 2: Nested Objects (Require Separate Entities)

2.1 Cancellation Policy

Canonical Class: CProductCancellation

package com.perun.tlinq.entity.product;

public class CProductCancellation extends TlinqEntity {
    private String productCode;        // FK to CProduct.prodCode
    private Integer windowHours;       // Hours before cutoff
    private String policyType;         // before_timeslot, before_date, never
}

Database Table: tiqets.product_cancellation

CREATE TABLE IF NOT EXISTS tiqets.product_cancellation (
    id INTEGER PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    supplier_product_id VARCHAR(30) NOT NULL REFERENCES tiqets.product(supplier_product_id),
    window_hours INTEGER,
    policy_type VARCHAR(20),  -- before_timeslot, before_date, never
    CONSTRAINT uk_prod_cancellation UNIQUE (supplier_product_id)
);

Native Entity: TqProductCancellation JPA Entity: ProductCancellationEntity


2.2 Starting Point

Canonical Class: CProductStartingPoint

package com.perun.tlinq.entity.product;

public class CProductStartingPoint extends TlinqEntity {
    private String productCode;        // FK to CProduct.prodCode
    private BigDecimal latitude;
    private BigDecimal longitude;
    private String address;
    private String cityId;
    private String label;
}

Database Table: tiqets.product_starting_point

CREATE TABLE IF NOT EXISTS tiqets.product_starting_point (
    id INTEGER PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    supplier_product_id VARCHAR(30) NOT NULL REFERENCES tiqets.product(supplier_product_id),
    latitude DECIMAL(10,7),
    longitude DECIMAL(10,7),
    address VARCHAR(500),
    city_id VARCHAR(30),
    label VARCHAR(255),
    CONSTRAINT uk_prod_starting_point UNIQUE (supplier_product_id)
);

Native Entity: TqProductStartingPoint JPA Entity: ProductStartingPointEntity


2.3 Supplier Info

Canonical Class: CProductSupplier

package com.perun.tlinq.entity.product;

public class CProductSupplier extends TlinqEntity {
    private String productCode;        // FK to CProduct.prodCode
    private String supplierName;
    private String supplierAddress;
    private String supplierCity;
    private String supplierPostalcode;
    private String supplierCountrycode;
}

Database Table: tiqets.product_supplier

CREATE TABLE IF NOT EXISTS tiqets.product_supplier (
    id INTEGER PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    supplier_product_id VARCHAR(30) NOT NULL REFERENCES tiqets.product(supplier_product_id),
    supplier_name VARCHAR(255),
    supplier_address VARCHAR(500),
    supplier_city VARCHAR(255),
    supplier_postalcode VARCHAR(50),
    supplier_countrycode VARCHAR(10),
    CONSTRAINT uk_prod_supplier UNIQUE (supplier_product_id)
);

Native Entity: TqProductSupplier JPA Entity: ProductSupplierEntity


2.4 Exhibitions (One-to-Many)

Canonical Class: CProductExhibition

package com.perun.tlinq.entity.product;

public class CProductExhibition extends TlinqEntity {
    private String productCode;        // FK to CProduct.prodCode
    private String exhibitionId;       // Tiqets exhibition ID
    private String title;
    private String description;
    private Date fromDate;
    private Date toDate;
    private String exhibitionUrl;
}

Database Table: tiqets.product_exhibition

CREATE TABLE IF NOT EXISTS tiqets.product_exhibition (
    id INTEGER PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    supplier_product_id VARCHAR(30) NOT NULL REFERENCES tiqets.product(supplier_product_id),
    supplier_exhibition_id VARCHAR(30) NOT NULL,
    title VARCHAR(500),
    description TEXT,
    from_date DATE,
    to_date DATE,
    exhibition_url VARCHAR(500),
    CONSTRAINT uk_prod_exhibition UNIQUE (supplier_product_id, supplier_exhibition_id)
);
CREATE INDEX IF NOT EXISTS idx_prod_exhibition_product ON tiqets.product_exhibition(supplier_product_id);

Native Entity: TqProductExhibition JPA Entity: ProductExhibitionEntity


2.5 Product Images (One-to-Many)

Canonical Class: CProductImage

package com.perun.tlinq.entity.product;

public class CProductImage extends TlinqEntity {
    private String productCode;        // FK to CProduct.prodCode
    private Integer imageOrder;        // Display order
    private String smallUrl;           // Small image URL
    private String mediumUrl;          // Medium image URL
    private String largeUrl;           // Large image URL
    private String extraLargeUrl;      // Extra large image URL
    private String altText;            // Alt text for accessibility
    private String credit;             // Image credit information
}

Database Table: tiqets.product_image

CREATE TABLE IF NOT EXISTS tiqets.product_image (
    id INTEGER PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    supplier_product_id VARCHAR(30) NOT NULL REFERENCES tiqets.product(supplier_product_id),
    image_order INTEGER NOT NULL DEFAULT 0,
    small_url VARCHAR(500),
    medium_url VARCHAR(500),
    large_url VARCHAR(500),
    extra_large_url VARCHAR(500),
    alt_text VARCHAR(500),
    credit VARCHAR(500),
    CONSTRAINT uk_prod_image UNIQUE (supplier_product_id, image_order)
);
CREATE INDEX IF NOT EXISTS idx_prod_image_product ON tiqets.product_image(supplier_product_id);

Native Entity: TqProductImage JPA Entity: ProductImageEntity


2.6 Product Groups (One-to-Many with nested products)

Canonical Class: CProductGroup

package com.perun.tlinq.entity.product;

public class CProductGroup extends TlinqEntity {
    private String productCode;        // FK to CProduct.prodCode
    private String groupId;            // Group ID (main product ID)
    private String groupType;          // booking_sources, upsell, comparison
    private String groupProductIds;    // Comma-separated product IDs in group
}

Database Table: tiqets.product_group

CREATE TABLE IF NOT EXISTS tiqets.product_group (
    id INTEGER PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    supplier_product_id VARCHAR(30) NOT NULL REFERENCES tiqets.product(supplier_product_id),
    group_id VARCHAR(30) NOT NULL,
    group_type VARCHAR(30),  -- booking_sources, upsell, comparison
    group_product_ids TEXT,  -- Comma-separated IDs
    CONSTRAINT uk_prod_group UNIQUE (supplier_product_id, group_id)
);
CREATE INDEX IF NOT EXISTS idx_prod_group_product ON tiqets.product_group(supplier_product_id);

Native Entity: TqProductGroup JPA Entity: ProductGroupEntity


Part 3: Files to Create/Modify

New Files to Create

File Location Purpose
CProductCancellation.java tqapp/.../entity/product/ Canonical cancellation entity
CProductStartingPoint.java tqapp/.../entity/product/ Canonical starting point entity
CProductSupplier.java tqapp/.../entity/product/ Canonical supplier entity
CProductExhibition.java tqapp/.../entity/product/ Canonical exhibition entity
CProductGroup.java tqapp/.../entity/product/ Canonical product group entity
CProductImage.java tqapp/.../entity/product/ Canonical image entity
ProductCancellationEntity.java tqtiqets/.../db/ JPA entity
ProductStartingPointEntity.java tqtiqets/.../db/ JPA entity
ProductSupplierEntity.java tqtiqets/.../db/ JPA entity
ProductExhibitionEntity.java tqtiqets/.../db/ JPA entity
ProductGroupEntity.java tqtiqets/.../db/ JPA entity
ProductImageEntity.java tqtiqets/.../db/ JPA entity
TqProductCancellation.java tqtiqets/.../entity/ Native entity
TqProductStartingPoint.java tqtiqets/.../entity/ Native entity
TqProductSupplier.java tqtiqets/.../entity/ Native entity
TqProductExhibition.java tqtiqets/.../entity/ Native entity
TqProductGroup.java tqtiqets/.../entity/ Native entity
TqProductImage.java tqtiqets/.../entity/ Native entity

Files to Modify

File Changes
tqapp/.../entity/product/CProduct.java Add ~37 new simple fields
tqtiqets/.../remote/entity/TiqetsProduct.java Add new fields + nested DTO classes
tqtiqets/.../db/ProductEntity.java Add simple fields, update initFromRemote()
tqtiqets/.../entity/TqProduct.java Add new fields with @TlinqEntityField
config/db/tiqets-schema.sql Add columns + new tables
config/entities/product-entities.xml Add field mappings for all new fields
tqtiqets/.../service/StaticDataRefresher.java Handle nested entity refresh
tqtiqets/.../util/TiqetsCacheManager.java Cache nested entities

Part 4: XML Configuration Updates

product-entities.xml - New Entity Definitions

<!-- CProductCancellation Entity -->
<Entity name="CProductCancellation"
        class="com.perun.tlinq.entity.product.CProductCancellation"
        idField="productCode" defaultFactory="TiqetsServiceFactory">
    <EntityFactoryList>
        <Factory name="TiqetsServiceFactory"
                 nativeEntity="com.perun.tlinq.client.tiqets.entity.TqProductCancellation">
            <FieldMappingList>
                <FieldMapping targetField="productCode" sourceField="productId" mapping="DirectMapping"/>
                <FieldMapping targetField="windowHours" sourceField="windowHours" mapping="DirectMapping"/>
                <FieldMapping targetField="policyType" sourceField="policyType" mapping="DirectMapping"/>
            </FieldMappingList>
        </Factory>
    </EntityFactoryList>
</Entity>

<!-- CProductStartingPoint Entity -->
<Entity name="CProductStartingPoint"
        class="com.perun.tlinq.entity.product.CProductStartingPoint"
        idField="productCode" defaultFactory="TiqetsServiceFactory">
    <EntityFactoryList>
        <Factory name="TiqetsServiceFactory"
                 nativeEntity="com.perun.tlinq.client.tiqets.entity.TqProductStartingPoint">
            <FieldMappingList>
                <FieldMapping targetField="productCode" sourceField="productId" mapping="DirectMapping"/>
                <FieldMapping targetField="latitude" sourceField="latitude" mapping="DirectMapping"/>
                <FieldMapping targetField="longitude" sourceField="longitude" mapping="DirectMapping"/>
                <FieldMapping targetField="address" sourceField="address" mapping="DirectMapping"/>
                <FieldMapping targetField="cityId" sourceField="cityId" mapping="DirectMapping"/>
                <FieldMapping targetField="label" sourceField="label" mapping="DirectMapping"/>
            </FieldMappingList>
        </Factory>
    </EntityFactoryList>
</Entity>

<!-- Similar for CProductSupplier, CProductExhibition, CProductGroup -->

CProduct Field Mapping Updates

<!-- Add to existing CProduct TiqetsServiceFactory mapping -->

<!-- New simple field mappings -->
<FieldMapping targetField="retailPrice" sourceField="retailPrice" mapping="DirectMapping"/>
<FieldMapping targetField="priceSupplierCurrency" sourceField="priceSupplierCurrency" mapping="DirectMapping"/>
<FieldMapping targetField="supplierCurrencyCode" sourceField="supplierCurrency" mapping="DirectMapping"/>
<FieldMapping targetField="prediscountPrice" sourceField="prediscountPrice" mapping="DirectMapping"/>
<FieldMapping targetField="discountPercentage" sourceField="discountPercentage" mapping="DirectMapping"/>
<FieldMapping targetField="bookingFee" sourceField="bookingFee" mapping="DirectMapping"/>

<FieldMapping targetField="contentLanguage" sourceField="contentLanguage" mapping="DirectMapping"/>
<FieldMapping targetField="tagline" sourceField="tagline" mapping="DirectMapping"/>
<FieldMapping targetField="promoLabel" sourceField="promoLabel" mapping="DirectMapping"/>
<FieldMapping targetField="summary" sourceField="summary" mapping="DirectMapping"/>

<FieldMapping targetField="supportsCancellationInsurance" sourceField="supportsCancellationInsurance" mapping="DirectMapping"/>
<FieldMapping targetField="instantTicketDelivery" sourceField="instantTicketDelivery" mapping="DirectMapping"/>
<FieldMapping targetField="wheelchairAccess" sourceField="wheelchairAccess" mapping="DirectMapping"/>
<FieldMapping targetField="smartphoneTicket" sourceField="smartphoneTicket" mapping="DirectMapping"/>
<FieldMapping targetField="isPackage" sourceField="isPackage" mapping="DirectMapping"/>

<FieldMapping targetField="ratingAverage" sourceField="ratingAverage" mapping="DirectMapping"/>
<FieldMapping targetField="ratingCount" sourceField="ratingCount" mapping="DirectMapping"/>

<FieldMapping targetField="saleStatus" sourceField="saleStatus" mapping="DirectMapping"/>
<FieldMapping targetField="saleStatusReason" sourceField="saleStatusReason" mapping="DirectMapping"/>
<FieldMapping targetField="productUrl" sourceField="productUrl" mapping="DirectMapping"/>
<FieldMapping targetField="productCheckoutUrl" sourceField="productCheckoutUrl" mapping="DirectMapping"/>
<FieldMapping targetField="whatsIncluded" sourceField="whatsIncluded" mapping="DirectMapping"/>
<FieldMapping targetField="whatsExcluded" sourceField="whatsExcluded" mapping="DirectMapping"/>
<FieldMapping targetField="tagIds" sourceField="tagIds" mapping="DirectMapping"/>

<!-- Nested entity mappings (NestedMapping type) -->
<FieldMapping targetField="cancellation" sourceField="cancellation" mapping="NestedMapping">
    <NestedMapping entity="CProductCancellation" loadAction="read"/>
</FieldMapping>

<FieldMapping targetField="startingPoint" sourceField="startingPoint" mapping="NestedMapping">
    <NestedMapping entity="CProductStartingPoint" loadAction="read"/>
</FieldMapping>

<FieldMapping targetField="supplier" sourceField="supplier" mapping="NestedMapping">
    <NestedMapping entity="CProductSupplier" loadAction="read"/>
</FieldMapping>

<FieldMapping targetField="exhibitions" sourceField="exhibitions" mapping="ArrayMapping">
    <ArrayMapping entity="CProductExhibition" loadAction="search"/>
</FieldMapping>

<FieldMapping targetField="productGroups" sourceField="productGroups" mapping="ArrayMapping">
    <ArrayMapping entity="CProductGroup" loadAction="search"/>
</FieldMapping>

<FieldMapping targetField="images" sourceField="images" mapping="ArrayMapping">
    <ArrayMapping entity="CProductImage" loadAction="search"/>
</FieldMapping>

Part 5: Refresh Process Updates

StaticDataRefresher.java Changes

// In saveProduct() method, after saving main product:

private void saveProduct(TiqetsProduct remote, String supplierExperienceId) {
    // ... existing product save logic ...

    // Save nested entities
    saveCancellation(remote, productEntity.getSupplierProductId());
    saveStartingPoint(remote, productEntity.getSupplierProductId());
    saveSupplier(remote, productEntity.getSupplierProductId());
    saveExhibitions(remote, productEntity.getSupplierProductId());
    saveProductGroups(remote, productEntity.getSupplierProductId());
    saveImages(remote, productEntity.getSupplierProductId());
}

private void saveCancellation(TiqetsProduct remote, String supplierProductId) {
    if (remote.getCancellation() == null) return;

    try (Session session = TiqetsDBSession.getSession()) {
        Transaction tx = session.beginTransaction();

        ProductCancellationEntity entity = session
            .createNamedQuery("ProductCancellationEntity.findByProductId", ProductCancellationEntity.class)
            .setParameter("productId", supplierProductId)
            .uniqueResultOptional()
            .orElse(new ProductCancellationEntity());

        entity.setSupplierProductId(supplierProductId);
        entity.setWindowHours(remote.getCancellation().getWindow());
        entity.setPolicyType(remote.getCancellation().getPolicy());

        session.merge(entity);
        tx.commit();
    }
}

private void saveExhibitions(TiqetsProduct remote, String supplierProductId) {
    if (remote.getExhibitions() == null || remote.getExhibitions().isEmpty()) return;

    try (Session session = TiqetsDBSession.getSession()) {
        Transaction tx = session.beginTransaction();

        // Delete existing exhibitions for this product
        session.createQuery("DELETE FROM ProductExhibitionEntity WHERE supplierProductId = :productId")
            .setParameter("productId", supplierProductId)
            .executeUpdate();

        // Insert new exhibitions
        for (TiqetsExhibition exh : remote.getExhibitions()) {
            ProductExhibitionEntity entity = new ProductExhibitionEntity();
            entity.setSupplierProductId(supplierProductId);
            entity.setSupplierExhibitionId(String.valueOf(exh.getId()));
            entity.setTitle(exh.getTitle());
            entity.setDescription(exh.getDescription());
            entity.setFromDate(parseDate(exh.getFromDate()));
            entity.setToDate(parseDate(exh.getToDate()));
            entity.setExhibitionUrl(exh.getUrl());
            session.persist(entity);
        }

        tx.commit();
    }
}

private void saveImages(TiqetsProduct remote, String supplierProductId) {
    if (remote.getImages() == null || remote.getImages().isEmpty()) return;

    try (Session session = TiqetsDBSession.getSession()) {
        Transaction tx = session.beginTransaction();

        // Delete existing images for this product
        session.createQuery("DELETE FROM ProductImageEntity WHERE supplierProductId = :productId")
            .setParameter("productId", supplierProductId)
            .executeUpdate();

        // Insert new images with order
        int order = 0;
        for (TiqetsImage img : remote.getImages()) {
            ProductImageEntity entity = new ProductImageEntity();
            entity.setSupplierProductId(supplierProductId);
            entity.setImageOrder(order++);
            entity.setSmallUrl(img.getSmall());
            entity.setMediumUrl(img.getMedium());
            entity.setLargeUrl(img.getLarge());
            entity.setExtraLargeUrl(img.getExtraLarge());
            entity.setAltText(img.getAltText());
            entity.setCredit(img.getCredit());
            session.persist(entity);
        }

        tx.commit();
    }
}

// Similar methods for saveStartingPoint(), saveSupplier(), saveProductGroups()

Part 6: Implementation Order

Phase 1: Database Schema

  1. Add new columns to tiqets.product table for simple fields
  2. Create new tables for nested entities (cancellation, starting_point, supplier, exhibition, product_group)
  3. Run schema migration

Phase 2: Remote DTO

  1. Add nested DTO classes to TiqetsProduct.java
  2. Add new simple fields to TiqetsProduct.java
  3. Test JSON deserialization

Phase 3: JPA Entities

  1. Create JPA entities for nested tables
  2. Add fields to ProductEntity.java
  3. Update initFromRemote() for simple fields

Phase 4: Refresh Process

  1. Add save methods for nested entities in StaticDataRefresher
  2. Update saveProduct() to call nested save methods
  3. Test refresh with API data

Phase 5: Native Entities

  1. Add fields to TqProduct.java
  2. Create TqProductCancellation, TqProductStartingPoint, etc.
  3. Add @TlinqEntityField annotations

Phase 6: Canonical Entities

  1. Add fields to CProduct.java
  2. Create CProductCancellation, CProductStartingPoint, etc.

Phase 7: XML Mappings

  1. Add simple field mappings to product-entities.xml
  2. Add nested entity definitions
  3. Add NestedMapping and ArrayMapping configurations

Phase 8: Testing

  1. Run full refresh
  2. Verify database population
  3. Test API responses with new fields
  4. Verify other plugins (Odoo, Rayna) unaffected

Verification Steps

  1. Schema Migration:

    psql -d tlinq -f config/db/tiqets-schema.sql
    

  2. Build:

    ./gradlew :tqtiqets:build
    ./gradlew :tqapp:build
    

  3. Refresh Data:

    ./gradlew :tqtiqets:test --tests "*.TiqetsSupplierIntegrationTest.testRefreshAllFetchesProducts"
    

  4. Verify Nested Tables:

    SELECT p.supplier_product_id, p.title,
           c.window_hours, c.policy_type,
           s.supplier_name,
           (SELECT COUNT(*) FROM tiqets.product_image i WHERE i.supplier_product_id = p.supplier_product_id) as image_count
    FROM tiqets.product p
    LEFT JOIN tiqets.product_cancellation c ON p.supplier_product_id = c.supplier_product_id
    LEFT JOIN tiqets.product_supplier s ON p.supplier_product_id = s.supplier_product_id
    WHERE p.active = true
    LIMIT 5;
    

  5. Test API:

  6. Call tiqets/products endpoint
  7. Verify nested objects in response
  8. Test with Odoo product endpoint - should be unaffected

Risk Considerations

  1. Performance: Nested entity loading may add latency. Consider eager vs lazy loading strategy.

  2. Null Handling: Many nested objects may be null in API response. All save methods must handle null gracefully.

  3. One-to-Many Refresh: For exhibitions and product_groups, need to delete existing records before inserting new ones to handle removals.

  4. Cache Invalidation: TiqetsCacheManager may need updates to cache nested entities or load them on demand.

  5. Backward Compatibility: Existing API consumers should still work with additional fields (additive change).


Part 7: Experience API Enhancements

The Experience API is already well-implemented. Most fields from the Tiqets Experience API are captured. This section documents the missing fields to add.

7.1 Missing Simple Fields for CExperience

Content (1 field): - contentLanguage - String - Language code of content (ISO 639-1)

Location Names (2 fields): - cityName - String - Localized city name - countryName - String - Localized country name

Image (1 field): - imageCredit - String - Image credit information (currently missing)

7.2 Database Schema Updates

Add columns to tiqets.experience table:

ALTER TABLE tiqets.experience ADD COLUMN IF NOT EXISTS content_language VARCHAR(10);
ALTER TABLE tiqets.experience ADD COLUMN IF NOT EXISTS city_name VARCHAR(255);
ALTER TABLE tiqets.experience ADD COLUMN IF NOT EXISTS country_name VARCHAR(255);
ALTER TABLE tiqets.experience ADD COLUMN IF NOT EXISTS image_credit VARCHAR(500);

7.3 Files to Modify for Experience

File Changes
tqapp/.../entity/product/CExperience.java Add 4 new fields + images array
tqtiqets/.../remote/entity/TiqetsExperience.java Add missing fields to DTO
tqtiqets/.../db/ExperienceEntity.java Add 4 columns, update initFromRemote()
tqtiqets/.../entity/TqExperience.java Add 4 fields with @TlinqEntityField
tqtiqets/.../service/StaticDataRefresher.java Add saveExperienceImages() method
config/db/tiqets-schema.sql Add columns + experience_image table
config/entities/product-entities.xml Add field mappings + CExperienceImage entity

7.4 Experience Images (Nested Entity)

Current State: Only the first image is stored (imageSmall, imageMedium, imageLarge, imageXlarge, imageAltText).

API Reality: The API returns an array of images.

Solution: Create tiqets.experience_image table similar to tiqets.product_image.

Canonical Class: CExperienceImage

package com.perun.tlinq.entity.product;

public class CExperienceImage extends TlinqEntity {
    private String experienceId;       // FK to CExperience.experienceId
    private Integer imageOrder;        // Display order
    private String smallUrl;           // Small image URL
    private String mediumUrl;          // Medium image URL
    private String largeUrl;           // Large image URL
    private String extraLargeUrl;      // Extra large image URL
    private String altText;            // Alt text for accessibility
    private String credit;             // Image credit information
}

Database Table: tiqets.experience_image

CREATE TABLE IF NOT EXISTS tiqets.experience_image (
    id INTEGER PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    supplier_experience_id VARCHAR(30) NOT NULL REFERENCES tiqets.experience(supplier_experience_id),
    image_order INTEGER NOT NULL DEFAULT 0,
    small_url VARCHAR(500),
    medium_url VARCHAR(500),
    large_url VARCHAR(500),
    extra_large_url VARCHAR(500),
    alt_text VARCHAR(500),
    credit VARCHAR(500),
    CONSTRAINT uk_exp_image UNIQUE (supplier_experience_id, image_order)
);
CREATE INDEX IF NOT EXISTS idx_exp_image_experience ON tiqets.experience_image(supplier_experience_id);

Native Entity: TqExperienceImage

package com.perun.tlinq.client.tiqets.entity;

@TlinqClientEntity
public class TqExperienceImage extends TqEntity {
    @TlinqEntityField(sourceName = "ExperienceImageEntity.supplierExperienceId")
    String experienceId;

    @TlinqEntityField(sourceName = "ExperienceImageEntity.imageOrder")
    Integer imageOrder;

    @TlinqEntityField(sourceName = "ExperienceImageEntity.smallUrl")
    String smallUrl;

    @TlinqEntityField(sourceName = "ExperienceImageEntity.mediumUrl")
    String mediumUrl;

    @TlinqEntityField(sourceName = "ExperienceImageEntity.largeUrl")
    String largeUrl;

    @TlinqEntityField(sourceName = "ExperienceImageEntity.extraLargeUrl")
    String extraLargeUrl;

    @TlinqEntityField(sourceName = "ExperienceImageEntity.altText")
    String altText;

    @TlinqEntityField(sourceName = "ExperienceImageEntity.credit")
    String credit;

    // Getters and setters...
}

JPA Entity: ExperienceImageEntity

package com.perun.tlinq.client.tiqets.db;

@Entity
@Table(name = "experience_image", schema = "tiqets")
public class ExperienceImageEntity implements Serializable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(name = "supplier_experience_id", nullable = false)
    private String supplierExperienceId;

    @Column(name = "image_order", nullable = false)
    private Integer imageOrder;

    @Column(name = "small_url")
    private String smallUrl;

    @Column(name = "medium_url")
    private String mediumUrl;

    @Column(name = "large_url")
    private String largeUrl;

    @Column(name = "extra_large_url")
    private String extraLargeUrl;

    @Column(name = "alt_text")
    private String altText;

    @Column(name = "credit")
    private String credit;

    // Getters and setters...
}

XML Mapping for CExperienceImage

<!-- CExperienceImage Entity -->
<Entity name="CExperienceImage"
        class="com.perun.tlinq.entity.product.CExperienceImage"
        idField="experienceId" defaultFactory="TiqetsServiceFactory">
    <EntityFactoryList>
        <Factory name="TiqetsServiceFactory"
                 nativeEntity="com.perun.tlinq.client.tiqets.entity.TqExperienceImage">
            <FieldMappingList>
                <FieldMapping targetField="experienceId" sourceField="experienceId" mapping="DirectMapping"/>
                <FieldMapping targetField="imageOrder" sourceField="imageOrder" mapping="DirectMapping"/>
                <FieldMapping targetField="smallUrl" sourceField="smallUrl" mapping="DirectMapping"/>
                <FieldMapping targetField="mediumUrl" sourceField="mediumUrl" mapping="DirectMapping"/>
                <FieldMapping targetField="largeUrl" sourceField="largeUrl" mapping="DirectMapping"/>
                <FieldMapping targetField="extraLargeUrl" sourceField="extraLargeUrl" mapping="DirectMapping"/>
                <FieldMapping targetField="altText" sourceField="altText" mapping="DirectMapping"/>
                <FieldMapping targetField="credit" sourceField="credit" mapping="DirectMapping"/>
            </FieldMappingList>
        </Factory>
    </EntityFactoryList>
</Entity>

Add to CExperience mapping:

<FieldMapping targetField="images" sourceField="images" mapping="ArrayMapping">
    <ArrayMapping entity="CExperienceImage" loadAction="search"/>
</FieldMapping>

StaticDataRefresher Update for Experience Images

private void saveExperienceImages(TiqetsExperience remote, String supplierExperienceId) {
    if (remote.getImages() == null || remote.getImages().isEmpty()) return;

    try (Session session = TiqetsDBSession.getSession()) {
        Transaction tx = session.beginTransaction();

        // Delete existing images for this experience
        session.createQuery("DELETE FROM ExperienceImageEntity WHERE supplierExperienceId = :experienceId")
            .setParameter("experienceId", supplierExperienceId)
            .executeUpdate();

        // Insert new images with order
        int order = 0;
        for (TiqetsImage img : remote.getImages()) {
            ExperienceImageEntity entity = new ExperienceImageEntity();
            entity.setSupplierExperienceId(supplierExperienceId);
            entity.setImageOrder(order++);
            entity.setSmallUrl(img.getSmall());
            entity.setMediumUrl(img.getMedium());
            entity.setLargeUrl(img.getLarge());
            entity.setExtraLargeUrl(img.getExtraLarge());
            entity.setAltText(img.getAltText());
            entity.setCredit(img.getCredit());
            session.persist(entity);
        }

        tx.commit();
    }
}

Files to Create for Experience Images

File Location Purpose
CExperienceImage.java tqapp/.../entity/product/ Canonical image entity
ExperienceImageEntity.java tqtiqets/.../db/ JPA entity
TqExperienceImage.java tqtiqets/.../entity/ Native entity

Migration Note

After implementing experience_image table, the existing single-image fields (imageSmall, imageMedium, etc.) in ExperienceEntity can be: - Option A: Kept for backward compatibility and quick access to primary image - Option B: Deprecated and removed in a future release

Recommendation: Keep them for now as they provide quick access to the primary image without joining.

7.5 Experience API Field Comparison

API Field JPA Entity Status
id supplierExperienceId
type experienceType
language contentLanguage ADD
title title
images[] experience_image table ADD (nested)
address.street streetAddress
address.postal_code postalCode
address.city_id supplierCityId
address.city_name cityName ADD
address.country_id supplierCountryId
address.country_name countryName ADD
address.lat latitude
address.lng longitude
address.google_place_id googlePlaceId
tag_ids tagIds
product_ids productIds
experience_url experienceUrl
ratings.average ratingAverage
ratings.total ratingCount
currency currency
from_price fromPrice
tagline tagline
description description

Note: Existing single-image fields (imageSmall, imageMedium, etc.) will be kept for backward compatibility and quick access to primary image.


Part 8: Refactoring

8.1 Rename StaticDataRefresher to TiqetsDataRefresher

Rationale: The name StaticDataRefresher is generic and doesn't indicate which plugin it belongs to. Renaming to TiqetsDataRefresher follows the naming convention used by other plugin-specific classes (e.g., TiqetsApiClient, TiqetsCacheManager).

Changes Required:

Action Details
Rename class StaticDataRefresher.javaTiqetsDataRefresher.java
Update package Keep in com.perun.tlinq.client.tiqets.service
Update references All files that import/use StaticDataRefresher

Files to Update:

tqtiqets/src/main/java/com/perun/tlinq/client/tiqets/service/
├── StaticDataRefresher.java  →  TiqetsDataRefresher.java

# Update references in:
- TiqetsSupplierIntegrationTest.java
- Any other test files using StaticDataRefresher
- TiqetsCacheManager.java (if referenced)
- TiqetsPlugin.java (if referenced)

Search for references:

grep -r "StaticDataRefresher" --include="*.java" tqtiqets/