Skip to content

Audience & Campaign Management

Flow ID: AD-22 Module(s): audience, campaigns, plus Complexity: Medium Last Updated: 2026-04-08

Business Overview

Audiences are criteria-based customer segments used for personalized experiences. Customers are auto-assigned to audiences via background jobs based on 8 tag groups (vendor, category, registration, referral, turnover, avg cart, country, county). Audiences gate access to coupons, slides (campaigns), and gift rules. The system uses AND/OR/ALL criteria logic for flexible segment definition.

Tag generation runs daily via AdvAddTagsToCustomers, analyzing order history, customer profiles, and geographic data. Audience membership is then computed by AdvAddCustomersToSpecificAudience which compiles SQL from criteria and bulk-inserts matching customer IDs. The entire pipeline is feature-gated by SMART_RECOMMENDATIONS.IS_ENABLED_CUSTOMER_TAG.

Both a legacy admin CRUD interface and a modern REST API with full CRUD operate on the audience data model.

API Reference

REST Endpoints (Modern Layer)

Audience Endpoints:

MethodPathControllerDescription
GET/rest/plus/audienceAdvisable\Rest\Plus\Controllers\Audience::indexList all audiences (paginated, filterable)
GET/rest/plus/audience/itemAudience::itemField metadata
GET/rest/plus/audience/{id}Audience::showSingle audience with details
POST/rest/plus/audienceAudience::storeCreate audience
POST/rest/plus/audience/{id}Audience::updateUpdate audience
DELETE/rest/plus/audience/{id}Audience::destroyDelete audience

Audience Criteria Endpoints:

MethodPathControllerDescription
GET/rest/plus/audience-criteriaAdvisable\Rest\Plus\Controllers\AudienceCriteria::indexList all criteria (paginated, filterable)
GET/rest/plus/audience-criteria/itemAudienceCriteria::itemField metadata
GET/rest/plus/audience-criteria/{id}AudienceCriteria::showSingle criterion
POST/rest/plus/audience-criteriaAudienceCriteria::storeCreate criterion
POST/rest/plus/audience-criteria/{id}AudienceCriteria::updateUpdate criterion
DELETE/rest/plus/audience-criteria/{id}AudienceCriteria::destroyDelete criterion

All endpoints also support language-prefixed routes: /{lang}/rest/plus/audience/....

Total: 12 REST endpoints (6 per entity x 2 entities), each duplicated with language prefix = 24 route entries.

Legacy Admin Routes

RouteControllerDescriptionRequired Roles
audience_adminAdv_audience_admin::indexAudience list with member counts and campaign countsADVISABLE, ADMIN, MARKETING
audience_admin/addAdv_audience_admin::addCreate audience with criteriaADVISABLE, ADMIN, MARKETING
audience_admin/edit/{id}Adv_audience_admin::editEdit audience criteriaADVISABLE, ADMIN, MARKETING
audience_admin/delete/{id}Adv_audience_admin::deleteDelete audience + criteria + membershipsADVISABLE, ADMIN, MARKETING
audience_admin/getCustomersAudience/{id}Adv_audience_admin::getCustomersAudienceCSV export of audience membersADVISABLE, ADMIN, MARKETING

Background Jobs

Job ClassCLI CommandDescription
AdvAddTagsToCustomersphp cli.php job/AddTagsToCustomersGenerate customer tags from order data (runs daily)
AdvAddCustomersToAudiencephp cli.php job/AddCustomersToAudienceEnqueue per-audience membership rebuild jobs for all audiences
AdvAddCustomersToSpecificAudiencephp cli.php job/AddCustomersToSpecificAudience/{audienceId}Rebuild membership for a specific audience

Code Flow

Tag Generation Pipeline

AdvAddTagsToCustomers::executeCommand([])
  |-- $fromDate = yesterday (incremental, last 24h)
  |-- customer_tag_model->saveCustomersTags($fromDate)
        |-- saveCategoryTags($fromDate)
        |     |-- INSERT IGNORE INTO shop_customer_tag(customer_id, tag_id, group_id, custom_value)
        |     |-- SELECT order.customer_id, product_category.category_id, GROUP_CATEGORY, NULL
        |     |-- FROM shop_order_basket
        |     |-- JOIN shop_order -> JOIN shop_customer (is_guest=0, is_sanitized=0)
        |     |-- JOIN product_codes -> JOIN shop_product_category_lp
        |     \-- WHERE gift_id IS NULL [exclude gift items]
        |
        |-- saveVendorTags($fromDate)
        |     |-- Same pattern: tag_id = shop_product.vendor_id
        |     \-- GROUP_VENDOR=1
        |
        |-- saveRegisteredTags($fromDate)
        |     |-- INSERT IGNORE: tag_id = IF(is_guest=0, 1, 0)
        |     |-- GROUP_REGISTERED=3
        |     \-- WHERE is_guest=0, is_sanitized=0
        |
        |-- saveLastReferralTags($fromDate)
        |     |-- Complex self-join: find last order per customer
        |     |-- tag_id = IFNULL(skroutz_referer, 0)
        |     |-- GROUP_LAST_REFERRAL=4
        |     \-- Delete-then-insert (last referral changes per customer)
        |
        |-- saveCountryTags($fromDate)
        |     |-- REPLACE INTO: tag_id=0, custom_value=pricing_country
        |     |-- GROUP_COUNTRY=7
        |     \-- Validated against country table
        |
        |-- saveCountyTags($fromDate)
        |     |-- REPLACE INTO: tag_id=0, custom_value=pricing_county
        |     |-- GROUP_COUNTY=8
        |     \-- Validated against county table
        |
        |-- saveTurnoverTags()  [always full recalc, no fromDate]
        |     |-- REPLACE INTO: tag_id=0, custom_value=SUM(total_vat)
        |     |-- GROUP_TURNOVER=5
        |     \-- Only orders with status IN ('SENT', 'PAID_SENT', 'INVOICED')
        |
        \-- saveAvgCartTags()  [always full recalc, no fromDate]
              |-- REPLACE INTO: tag_id=0, custom_value=AVG(total_vat)
              |-- GROUP_AVG_CART=6
              \-- Only orders with status IN ('SENT', 'PAID_SENT', 'INVOICED')

Audience Membership Rebuild

AdvAddCustomersToAudience::executeCommand([])
  |-- audience_model->getRecords()  [all audiences]
  |-- For each audience:
  |     |-- Schedule AddCustomersToSpecificAudience job
  |     |-- Stagger: +5 minutes per audience
  |     \-- Queue: 'personalization', grace_time: 60s, retries: 1

AdvAddCustomersToSpecificAudience::executeCommand(['audience' => $id])
  |-- audience_model->saveCustomersToAudience($audienceId)
        |-- getCustomersOfAudienceCompile($audienceId)
        |     |-- Load audience criteria
        |     |-- For criteria_type AND (2):
        |     |     |-- Build SQL with INNER JOINs for each criterion
        |     |     \-- All criteria must match
        |     |-- For criteria_type OR (1):
        |     |     |-- Build SQL with single JOIN + multiple WHERE conditions
        |     |     \-- Any criterion may match
        |     |-- For criteria_type ALL (null):
        |     |     \-- SELECT all non-guest, non-sanitized customers
        |     \-- Return compiled SQL
        |-- DELETE FROM shop_customer_audience WHERE audience_id = $id
        \-- INSERT INTO shop_customer_audience (audience_id, customer_id)
              SELECT $id, tt.id FROM ({compiled_sql}) AS tt

Admin Audience CRUD

Adv_audience_admin::add()
  |-- Validate form (name, tagType, tags[group_id] arrays)
  |-- audience_model->addRecord($audience, $criteria)
  |     |-- Insert into audience table (name, user_id, criteria_type, creation_datetime)
  |     |-- Build criteria batch from tags:
  |     |     |-- Groups with custom values (AVG.CART, COUNTRY, COUNTY):
  |     |     |     \-- Store tag_id + custom_value per criterion
  |     |     |-- Groups with tag IDs (VENDOR, CATEGORY, etc.):
  |     |     |     \-- Store tag_id per criterion
  |     |     \-- INSERT batch into audience_criteria
  |     \-- Return audienceId
  |-- afterAdd($audienceId)
  |     \-- Enqueue AddCustomersToSpecificAudience job (immediate, queue=personalization)

Adv_audience_admin::edit($id)
  |-- audience_model->updateRecord($id, $audience, $criteria)
  |     |-- UPDATE audience table
  |     |-- DELETE all existing criteria for this audience
  |     \-- INSERT new criteria batch
  |-- afterEdit($id)
  |     \-- Enqueue AddCustomersToSpecificAudience job (immediate rebuild)

Frontend Audience Check (Every Storefront Page)

Adv_front_controller::__construct()
  |-- setCustomerAudience()
  |     |-- Guard: customer_logged_in AND SMART_RECOMMENDATIONS.IS_ENABLED_CUSTOMER_TAG
  |     |-- audience_model->getCustomerAudience($customer_id)
  |     |     \-- SELECT audience_id FROM shop_customer_audience WHERE customer_id = $id
  |     \-- CartResource::getInstance()->setCustomerAudience($audienceIds)
  |
  [Later, during coupon/slide rendering:]
  |-- EntityCustomerAudienceCheck::audiencesCheck($customerAudiences, $entityAudienceId)
  |     |-- null audience_id -> true (no restriction)
  |     |-- criteria_type ALL -> true
  |     |-- No criteria -> true
  |     |-- No customer audiences -> false
  |     \-- in_array($entityAudienceId, $customerAudiences)

Domain Layer

Modern Domain (src/Domains/Plus/)

Audience Entity:

FileDescription
Audience/Repository/Entity.phpProperties: id, name, criteria_type, creation_datetime, update_datetime, user_id
Audience/Repository/Repository.phpBaseRepository for audience table
Audience/Repository/RepositoryConfigurator.phpTable name and relationships
Audience/Repository/WriteRepository.phpInsert/update operations
Audience/Service.phpRead: match (paginated), get (single)
Audience/WriteService.phpWrite: store, update, destroy
Audience/WriteData.phpValue object for audience fields
Audience/Validator.phpValidates required fields
Audience/ListRequest.phpFilter/sort/pagination params

AudienceCriteria Entity:

FileDescription
AudienceCriteria/Repository/Entity.phpProperties: id, audience_id, tag_id, group_id, custom_value
AudienceCriteria/Repository/Repository.phpBaseRepository for audience_criteria table
AudienceCriteria/Repository/RepositoryConfigurator.phpTable name and relationships
AudienceCriteria/Repository/WriteRepository.phpInsert/update operations
AudienceCriteria/Service.phpRead operations
AudienceCriteria/WriteService.phpWrite operations
AudienceCriteria/WriteData.phpValue object for criteria fields
AudienceCriteria/Validator.phpValidates required fields
AudienceCriteria/ListRequest.phpFilter/sort/pagination params

DI Container: src/Domains/Plus/container.php

Legacy Models (ecommercen/audience/)

FileDescription
models/AdvCustomerTagModel.phpTag generation model (~500 lines). saveCustomersTags() runs all 8 tag generators. Each uses raw SQL with INSERT IGNORE or REPLACE INTO for idempotent bulk operations.
models/AdvAudienceModel.phpExtends AdvCustomerTagModel. Audience CRUD + audience compilation SQL. saveCustomersToAudience() compiles criteria into SQL and bulk-inserts matching customers.

Legacy Controllers

FileDescription
audience/controllers/Adv_audience_admin.phpAudience CRUD (290 lines). Uses AdminCustomersTagsFilters trait for filter dropdowns. Auto-triggers job on add/edit. CSV export of audience members.

Integration Traits and Models

FileDescription
ecommercen/traits/EntityCustomerAudienceCheck.phpaudiencesCheck() method used by coupons, slides, and cart resource to validate customer audience membership
ecommercen/coupons/models/Adv_coupons_model.phpUses EntityCustomerAudienceCheck for audience-restricted coupons
ecommercen/sliders/models/Adv_sliders_model.phpUses audience check for slide filtering
ecommercen/libraries/AdvCartResource.phpStores customer audiences for use during cart rendering
ecommercen/eshop/models/Adv_customer_campaign_model.phpTracks customer campaign participation

Architecture

8 Customer Tag Groups

Group IDConstant KeyTag Sourcetag_id Meaningcustom_value
1VENDORProducts purchasedshop_vendor.idNULL
2CATEGORYProducts purchasedshop_product_category.idNULL
3REGISTEREDAccount status1 (registered)NULL
4LAST.REFERRALLast order referralReferral channel ID (0=none)NULL
5TURNOVERLifetime spend0 (single entry)SUM(total_vat)
6AVG.CARTAverage order value0 (single entry)AVG(total_vat)
7COUNTRYBilling country0 (single entry)Country alpha-2 code
8COUNTYBilling county0 (single entry)County alpha code

Tag groups are defined as constants in application/config/constants.php:

php
define('CUSTOMER_TAGS_GROUPS', [
    'VENDOR' => 1, 'CATEGORY' => 2, 'REGISTERED' => 3, 'LAST.REFERRAL' => 4,
    'TURNOVER' => 5, 'AVG.CART' => 6, 'COUNTRY' => 7, 'COUNTY' => 8
]);

Criteria Types

ConstantValueSQL StrategyDescription
ALLnullSelect all non-guest customersAll customers match
OR1Single JOIN + multiple WHERE conditionsCustomer matches ANY criterion
AND2INNER JOIN per criterionCustomer matches ALL criteria
php
define('CRITERIA_TYPES', ['ALL' => null, 'OR' => 1, 'AND' => 2]);

Tag Generation Strategy

Groups 1-4Groups 5-8
Incremental: INSERT IGNORE with fromDate filterFull recalculation: REPLACE INTO (no date filter)
Order-history basedAggregate-based (SUM/AVG)
Grow over time (new tags added)Overwrite with latest values
Uses fromDate = yesterday for daily runsAlways recalculates for all customers

Data Flow Diagram

[Order History]           [Customer Profiles]
     |                          |
     v                          v
AdvAddTagsToCustomers (daily job)
     |
     v
[shop_customer_tag]  (tag_id + group_id per customer)
     |
     v
AdvAddCustomersToAudience (enqueue per-audience jobs)
     |
     v
AdvAddCustomersToSpecificAudience (per audience)
     |-- Load audience_criteria for this audience
     |-- Compile SQL based on criteria_type (AND/OR/ALL)
     |-- DELETE existing memberships
     \-- INSERT matching customer IDs
           |
           v
[shop_customer_audience]  (audience_id + customer_id)
     |
     +---> Coupon validation (EntityCustomerAudienceCheck)
     +---> Slide filtering (audience_id check)
     \---> Cart resource (customer audience list)

Data Model

audience Table

ColumnTypeDescription
idINT(11) UNSIGNED PK AIAudience ID
nameVARCHAR(255)Audience name
criteria_typeTINYINT(4) NULLnull=ALL, 1=OR, 2=AND
creation_datetimeDATETIMECreated timestamp
update_datetimeDATETIME NULLLast updated timestamp
user_idINT(11) NULLAdmin user who created/edited

Index: user_id.

audience_criteria Table

ColumnTypeDescription
idINT(11) UNSIGNED PK AICriterion ID
audience_idINT(11) UNSIGNEDFK to audience.id
tag_idINT(11)Tag identifier (vendor ID, category ID, 0 for value-based, etc.)
group_idINT(11)Tag group (1-8)
custom_valueVARCHAR(50) NULLFor value-based groups (AVG.CART ranges, country codes)

Unique key: (audience_id, tag_id, group_id, custom_value).

shop_customer_audience Table

ColumnTypeDescription
audience_idINT(11) UNSIGNEDFK to audience.id
customer_idINT(11)FK to shop_customer.id

Primary key: (audience_id, customer_id). Indexes: audience_id, customer_id.

shop_customer_tag Table

ColumnTypeDescription
idINT(11) PK AITag entry ID
customer_idINT(11)FK to shop_customer.id
tag_idINT(11)Identifier within group (vendor ID, category ID, 0, etc.)
group_idINT(11)Tag group (1-8)
custom_valueVARCHAR(50) NULLValue for value-based groups

Unique key: (customer_id, tag_id, group_id). Index: (group_id, custom_value). Engine: InnoDB (converted from MyISAM in migration 20260408100114).

shop_customer_campaign Table

ColumnTypeDescription
idINT(11) PK AICampaign entry ID
customer_idINT(11)FK to shop_customer.id
campaign_typeINT(11)Campaign type identifier
entry_datetimeDATETIMEWhen customer entered campaign

Indexes: entry_datetime, customer_id, campaign_type, (customer_id, campaign_type), (customer_id, campaign_type, entry_datetime).

Configuration

Feature Gate

The entire audience/tag system is gated by:

php
$this->registry->value('SMART_RECOMMENDATIONS', 'IS_ENABLED_CUSTOMER_TAG')

When disabled, the audience admin returns 404 and the frontend skips audience loading.

Job Scheduling

JobScheduleQueueGrace TimeRetries
AddTagsToCustomersDaily (cron)defaultN/AN/A
AddCustomersToAudienceDaily (after tags)personalization60s per audience1
AddCustomersToSpecificAudienceOn-demand (after audience edit)personalization60s1

Jobs are staggered by +5 minutes per audience when the bulk rebuild runs to prevent database overload.

ConfigLocationDescription
CUSTOMER_TAGS_GROUPSapplication/config/constants.phpGroup ID mapping
CRITERIA_TYPESapplication/config/constants.phpCriteria type constants
referrerChannelsapplication/config/referrer_channels.phpReferral channel definitions for LAST.REFERRAL tags

Client Extension Points

  • Adv_audience_admin has empty afterAdd($id) and afterDelete($id) hooks (both trigger job enqueue by default, but afterDelete is a no-op hook for client extension).
  • Custom tag groups: The system is limited to 8 built-in groups. Client repos can add custom SQL tag generators by extending AdvCustomerTagModel in application/models/.
  • Custom criteria logic: The getCustomersOfAudienceCompile() method can be overridden in client repos for custom audience compilation strategies.
  • REST endpoints: Can be overridden via DI alias in custom/Domains/Plus/container.php.

Business Rules

  • Gift items excluded: gift_id IS NULL filter ensures gift line items do not contribute to vendor/category tags.
  • Only real customers: Tags only generated for non-guest (is_guest = 0) and non-sanitized (is_sanitized = 0) customers.
  • Order status filter: Turnover and avg cart tags only count orders with status IN (SENT, PAID_SENT, INVOICED). Canceled and returned orders are excluded.
  • Delete-then-insert: Audience membership rebuild uses DELETE + INSERT pattern (not upsert) to handle removed customers.
  • Incremental vs full: Category, vendor, registered, referral, country, county tags are incremental (daily delta). Turnover and avg cart are always fully recalculated.
  • Last referral override: The LAST.REFERRAL tag group uses delete-then-insert per customer because only the most recent order's referral matters.
  • InnoDB for tags: shop_customer_tag was converted to InnoDB (migration 20260408100114_convert_myisam_tables_to_innodb.php). INSERT IGNORE continues to work identically under InnoDB; row-level locking replaces the former table-level lock.
  • Job staggering: Bulk audience rebuild staggers jobs by 5 minutes per audience to prevent DB contention.
  • CSV export: The getCustomersAudience() endpoint exports audience members as CSV (ID, email, phone, mobile, turnover value) with BOM bytes for Excel compatibility.
  • Audience deletion: Deletes from all three tables (audience, audience_criteria, shop_customer_audience). No cascade constraints -- handled in application code.