Jahia integration example

November 11, 2022
Note: Marketing Factory is renamed to jExperience in version 1.11 and Apache Unomi is renamed to jCustomer. The 1.10 documentation has been updated to reflect the product name change.

This topic shows how a Jahia component can interact with jCustomer to enrich a user's profile. The example shows how to create a tweet button component that can record the number of times the user tweeted (as a tweetNb integer property) and the URLs they tweeted from (as a tweetedFrom multi-valued string property).

To follow the example, you must first install and configure jExperience for the site on which the component is to be deployed. This makes accessing jCustomer much easier and avoids accessing jCustomer without jExperience installed.

Component overview

The code for the Jahia component can be found at: https://github.com/apache/incubator-unomi/tree/master/samples/tweet-button-plugin.

The component that you develop in this example is fairly simple and consists of a simple droppableContent called smp:twitterButton. The view for the component uses the standard Twitter button but defines a Javascript resource called twitterButton.js which performs the actual interaction with jCustomer. This Javascript code has been developed in two different versions, depending on how you interact with jCustomer. Also depending on that access modality, the component might need some supporting cast as you will see below.

Using the REST API

Let's first look at the Jahia component and try to use the jCustomer's REST API to achieve our goal for a pure client-side solution. To see the code for this version, you will need to switch to the using-api branch of the code (git co using-api).

Dealing with authentication

Since the jCustomer API is protected behind a login, you first need to be able to provide your authentication information to jCustomer. To make things transparent for the component, the solution leverages existing information provided by a properly set-up jExperience in the form of a filter injecting a globally-scoped Javascript variable called CXSAuthenticationHeader into Jahia pages.

This variable provides a Basic Authentication header token ready to be sent along our REST requests. The filter is called CXSSettingsInjectorFilter:

public String execute(String previousOut, RenderContext renderContext, Resource resource, RenderChain chain) throws Exception {
    final String siteKey = renderContext.getSite().getSiteKey();
    ContextServerSettings contextServerSettings = contextServerSettingsService.getSettings(siteKey);

    if (contextServerSettings == null) {
        // force a reload of settings
        contextServerSettingsService.afterPropertiesSet();

        // and re-attempt to get the settings
        contextServerSettings = contextServerSettingsService.getSettings(siteKey);

        if (contextServerSettings == null) {
            logger.error("Couldn't retrieve the settings for site " + siteKey + ". The twitter button component won't be working.");
            return previousOut;
        }
    }

    previousOut += "<script type=\"text/javascript\">var " + CXSAUTHORIZATION_HEADER + " ='" + generateBasicAuth(contextServerSettings) + "';</script>";
    return previousOut;
}

Ideally, we would retrieve the existing settings service to retrieve the Apache jCustomer configuration from jExperience. However, the service is not currently exposed for consumption in external modules thus requiring to get around this limitation to get it to work how we would like to. In particular, were the service cleanly exposed, it would react to changes to the settings, which is not currently the case. This explains also the tricky bit about reloading the settings if we couldn't retrieve them on first attempt for our site.

Also, since our retrieved service instance doesn't react to settings changes, you also require a Jahia EventListener to listen to updates to the settings node and inform the filter that it needs to regenerate the token on its next invocation. The listener is called CXSSettingsChangeListener.

Filter and listener are set up as usual using a Spring configuration file.

Twitter API setup

Let's now look at the javascript code: twitterButton.js.

The first section of the file is strictly twitter-related, so not particularly interesting for our purpose. The twitter widget is loaded asynchronously. We then register a callback to call when the widget is ready via the ready function. The callback function is the interesting part. It registers another callback that gets called any time the twitter widget emits the tweet event:

twttr.events.bind('tweet', function (event) { ... }

The callback provided to the Twitter API is where the magic happens: our user has tweeted so that's where we want to update their profile.

Callback utility functions

First, you register some utility functions to deal with calling to the jCustomer REST API via AJAX calls: we define a default error callback (defaultErrorCallback) to be used in case the AJAX call goes wrong, we define a function to ease the creation of CORS requests since it's likely our jCustomer server runs on a different server than the Jahia one (createCORSRequest) and finally we create a function to perform the actual AJAX call (performXHRRequest). These functions should actually be reusable in your project and not particularly interesting here. We will just talk briefly about some salient points of the performXHRRequest function:

function performXHRRequest(url, successCallback, errorCallback, data) {
    var method = data ? 'POST' : 'GET';
    var xhr = createCORSRequest(method, baseURL + url);
    if (!xhr) {
        alert('CORS not supported');
        return;
    }

    xhr.onerror = errorCallback || defaultErrorCallback;
    xhr.onreadystatechange = function () {
        if (xhr.readyState == 4 && xhr.status == 200) {
            successCallback(JSON.parse(xhr.responseText));
        }
    };

    // authenticate with context server
    xhr.setRequestHeader("Authorization", CXSAuthorizationHeader);
    if (!data) {
    // Use text/plain to avoid CORS preflight
        xhr.setRequestHeader("Content-Type", "text/plain;charset=UTF-8");
        xhr.send();
    } else {
        xhr.setRequestHeader("Content-Type", "application/json");
        xhr.send(JSON.stringify(data));
    }
}

The function takes four parameters: a (relative) URL to call (url), a function to call in case of successful call (successCallback), a function to call in case of an error (errorCallback) and optional data to send (data). We first create a CORS request, set the callbacks, all this being fairly standard javascript. We then use our injected CXSAuthorizationHeader to provide the appropriate value to the Authentication header that the AJAX request will provide the context server to authenticate to it. If we have any data to send, then we assume that it's JSON since this is what the context server expects and we set the proper Content-Type header. Nothing really complicated.

The CORS request is initiated to a URL that is built using the relative URL we provide to identify the REST resource handling the request and a base URL that is obtained from the window.digitalData object configured for us by jExperience.

Calling to jCustomer

Let's now look at the jCustomer REST calls. We will use a little helpful library (async.js) to simplify the sequence of calls since we need to make sure that some calls are performed before others can happen and in an asynchronous world, this can be a little challenging if we want to avoid nested callbacks (the so-called "callback hell"). async.js provides numerous functions but we will only use one of them: series which works by specifying an array of functions to be executed sequentially, calling a callback when done (we won't be using the callback either actually).

First, we need to make sure that jCustomer knows about the property types we want to use to enrich our users' profiles. In order to do that, we will ask jCustomer to retrieve all property types tagged with the social tag, which we will use for our property types, and check if any of the returned property types match the ones we're interested in. If we can't find them, then we need to create these property types.

To create these property types, we send the new property type as JSON data to the jCustomer /profiles/properties resource to specify we want to create a profile property with the given JSON payload. So to create our tweetNb integer property, we would call:

performXHRRequest('/profiles/properties',
    function (data) { console.log("Property type tweetNb successfully added!"); },
    defaultErrorCallback,
    {
        itemId: 'tweetNb',
        itemType: 'propertyType',
        metadata: {
            id: 'tweetNb',
            name: 'tweetNb'
        },
        tags: ['social'],
        target: 'profiles',
        type: 'integer'
    });

Here's the JSON definition for our tweetedFrom property type. Note the string value type and the multivalued property set to true:

{
  itemId: 'tweetedFrom',
  itemType: 'propertyType',
  metadata: {
    id: 'tweetedFrom',
    name: 'tweetedFrom'
  },
  tags: ['social'],
  target: 'profiles',
  type: 'string',
  multivalued: true
}

The createdTypesIfNeeded function encapsulates all that logic, expecting an array of property types returned by jCustomer:

var createTypesIfNeeded = function (data) {
    var foundTweetNb, foundTweetedFrom = false;
    for (var i in data) {
        if (data[i].itemId === 'tweetNb') {
            foundTweetNb = true;
        } else if (data[i].itemId === 'tweetedFrom') {
            foundTweetedFrom = true;
        }

        if (foundTweetNb && foundTweetedFrom) {
            // we found the property types, so abort search and return
            return;
        }
    }

    // we haven't found the property types, so create them
    performXHRRequest('/profiles/properties',
        function (data) {
            console.log("Property type tweetNb successfully added!");
        },
        defaultErrorCallback,
        {
            itemId: 'tweetNb',
            itemType: 'propertyType',
            metadata: {
                id: 'tweetNb',
                name: 'tweetNb'
            },
            tags: ['social'],
            target: 'profiles',
            type: 'integer'
        });
    performXHRRequest('/profiles/properties',
        function (data) {
            console.log("Property type tweetedFrom successfully added!");
        },
        defaultErrorCallback,
        {
            itemId: 'tweetedFrom',
            itemType: 'propertyType',
            metadata: {
                id: 'tweetedFrom',
                name: 'tweetedFrom'
            },
            tags: ['social'],
            target: 'profiles',
            type: 'string',
            multivalued: true
        });
};

Our createTypesIfNeeded function needs to be called before we actually perform any profile updates with these new property types since jCustomer would create the properties with a potentially wrong type. This is where async.js and its series function comes into play:

// call in sequence to make sure that property types are created
// before we update the profile
async.series([
    // check first if we already have defined the property types
    // we're interested in and create them if needed
    function (callback) {
        performXHRRequest('/profiles/properties/tags/social',
            createTypesIfNeeded, defaultErrorCallback);
        callback(null, null);
    },
    // then retrieve and update profile
    function (callback) {
        ...
        callback(null, null);
    }
]);

Only when we are sure the types are created can we attempt to update our user's profile. We first identify the context server resource associated with the profile by using the profileId property from the injected cxs object prefixed by the /profiles/ relative path. We can then retrieve the associated profile, providing a callback to call upon success. The callback itself retrieves the existing values for our tweetNb and tweetedFrom properties and updates them appropriately using the globally available information from window.digitalData. Once this is done, another call to the URI associated to our profile is performed, this time passing it our updated profile:

performXHRRequest('/profiles/' + cxs.profileId, function (profile) {
    var properties = profile.properties;
    var tweetNb = properties.tweetNb || 0;
    var tweetedFrom = properties.tweetedFrom || [];
    profile.properties.tweetNb = tweetNb + 1;
    var pageInfo = window.digitalData.page.pageInfo;
    if (pageInfo) {
        var url = pageInfo.destinationURL;
        if (url) {
            tweetedFrom.push(url);
            profile.properties.tweetedFrom = tweetedFrom;
        }
    }

    // and update it with the new value for tweetNb
    performXHRRequest('/profiles', function (profile) {
        console.log("Profile successfully updated with tweetNB = "
            + profile.properties.tweetNb);
        console.log("Profile successfully updated with tweetedFrom = "
            + profile.properties.tweetedFrom);
    }, defaultErrorCallback, profile)
});

As you can see, this solution is pretty involved though it has the advantage of being purely implemented on the client, which might be your only option if you don't have access to the jCustomer server.

We will now examine another solution using a more elaborate context request associated with a jCustomer plugin.

Using a context request and a jCustomer plugin

The code for this approach is located on the master branch of the our Jahia component (https://github.com/metacosm/unomi-tweet-button). This time, however, you will also need another component, a jCustomer plugin found at https://github.com/metacosm/unomi-tweet-button-plugin/tree/with-mf-1_0_0 (with-mf-1_0_0 branch).

Jahia component

The approach being different, our component, while globally similar, is greatly simplified in its interaction with the context server. We define a simpler version of the context server request as follows:

function contextRequest(successCallback, errorCallback, payload) {
    var data = JSON.stringify(payload);
    var url = window.digitalData.contextServerPublicUrl
        + '/context.json?sessionId=' + cxs.sessionId;
    var xhr = new XMLHttpRequest();
    var isGet = data.length < 100;
    if (isGet) {
        xhr.withCredentials = true;
        xhr.open("GET", url + "&payload=" + encodeURIComponent(data), true);
    } else if ("withCredentials" in xhr) {
        xhr.open("POST", url, true);
        xhr.withCredentials = true;
    } else if (typeof XDomainRequest != "undefined") {
        xhr = new XDomainRequest();
        xhr.open("POST", url);
    }
    xhr.onreadystatechange = function () {
        if (xhr.readyState != 4) {
            return;
        }
        if (xhr.status == 200) {
            var response = xhr.responseText ? JSON.parse(xhr.responseText)
                : undefined;
            successCallback(response);
        } else {
            console.log("contextserver: " + xhr.status
                + " ERROR: " + xhr.statusText);
            if (errorCallback) {
                errorCallback(xhr);
            }
        }
    };
    // Use text/plain to avoid CORS preflight
    xhr.setRequestHeader("Content-Type", "text/plain;charset=UTF-8");
    if (isGet) {
        xhr.send();
    } else {
        xhr.send(data);
    }
}

There are a couple of things to note here, in comparison with the version we used in the other approach:

  • we didn't separate the CORS request creation because we will only need to make one request
  • we're not calling the REST API but rather a single URI: window.digitalData.contextServerPublicUrl + '/context.json?sessionId=' + cxs.sessionId. This URI requests context from jCustomer, resulting in an updated cxs object in the javascript global scope. The context server can reply to this request either by returning a JSON-only object containing solely the context information as is the case when the requested URI is context.json. However, if the client requests context.js then useful functions to interact with jCustomer are added to the cxs object in addition to the context information as depicted above.
  • we don't need to provide any authentication at all to interact with this part of jCustomer since we only have access to read-only data (as well as providing events as we shall see later on). We therefore do not need to somehow retrieve the authentication information from jExperience.

The request to jCustomer itself is also a lot simpler: one single request / response, no nested callbacks or need to make sure that calls went through before being able to proceed with the next one:

var contextPayload = {
    source: {
        itemType: 'page',
        scope: window.digitalData.scope,
        itemId: window.digitalData.page.pageInfo.pageID,
        properties: window.digitalData.page
    },
    events: [
        {
            eventType: 'tweetEvent',
            scope: window.digitalData.scope,
            source: {
                itemType: 'page',
                scope: window.digitalData.scope,
                itemId: window.digitalData.page.pageInfo.pageID,
                properties: window.digitalData.page
            }
        }
    ],
    requiredProfileProperties: [
        'tweetNb',
        'tweetedFrom'
    ]
};

contextRequest(function (response) {
    console.log("Profile sucessfully updated with tweetNB = "
        + response.profileProperties.tweetNb + " and tweetedFrom = "
        + response.profileProperties.tweetedFrom);
}, defaultErrorCallback, contextPayload);

The interesting part in that context request is, of course, the payload. This is where we provide jCustomer with contextual information as well as ask for data in return. This allows clients to specify which type of information they are interested in getting from the context server as well as specify incoming events or content filtering or property/segment overrides for personalization or impersonation. This conditions what the context server will return with its response.

A context request payload needs to at least specify some information about the source of the request in the form of an Item (meaning identifier, type and scope plus any additional properties we might have to provide), via the source property of the payload. Of course the more information can be provided about the source, the better.

A client wishing to perform content personalization might also specify filtering condition to be evaluated by the context server so that it can tell the client whether the content associated with the filter should be activated for this profile/session. This is accomplished by providing a list of filter definitions to be evaluated by the context server via the filters field of the payload. If provided, the evaluation results will be provided in the filteringResults field of the resulting cxs object the context server will send.

It is also possible to clients wishing to perform user impersonation to specify properties or segments to override the proper ones so as to emulate a specific profile, in which case the overridden value will temporarily replace the proper values so that all rules will be evaluated with these values instead of the proper ones. The segmentOverrides (array of segment identifiers), profilePropertiesOverrides and sessionPropertiesOverrides (maps of property name and associated object value) fields allow to provide such information. Providing such overrides will, of course, impact content filtering results and segments matching for this specific request.

The clients can also specify which information to include in the response by setting the requiresSegments property to true if segments the current profile matches should be returned or provide an array of property identifiers for requiredProfileProperties or requiredSessionProperties fields to ask the context server to return the values for the specified profile or session properties, respectively. This information is provided by the profileProperties, sessionProperties and profileSegments fields of the context server response.

Additionally, the context server will also returns any tracked conditions associated with the source of the context request. Upon evaluating the incoming request, the context server will determine if there are any rules marked with the trackedCondition tag and which source condition matches the source of the incoming request and return these tracked conditions to the client. The client can use these tracked conditions to learn that the context server can react to events matching the tracked condition and coming from that source. This is, in particular, used to implement form mapping (a solution that allows clients to update user profiles based on values provided when a form is submitted).

Finally, the client can specify any events triggered by the user actions, so that the context server can process them, via the events field of the context request.

If no payload is specified, the context server will simply return the minimal information deemed necessary for client applications to properly function: profile identifier, session identifier and any tracked conditions that might exist for the source of the request.

Now that we've seen the structure of the request and what we can expect from the context response, let's examine the request our component is doing:

var contextPayload = {
    source: {
        itemType: 'page',
        scope: window.digitalData.scope,
        itemId: window.digitalData.page.pageInfo.pageID,
        properties: window.digitalData.page
    },
    events: [
        {
            eventType: 'tweetEvent',
            scope: window.digitalData.scope,
            source: {
                itemType: 'page',
                scope: window.digitalData.scope,
                itemId: window.digitalData.page.pageInfo.pageID,
                properties: window.digitalData.page
            }
        }
    ],
    requiredProfileProperties: [
        'tweetNb',
        'tweetedFrom'
    ]
};

Our context request payload specifies the source of the request by leveraging the window.digitalData information that jExperience automatically injects in the javascript global scope. We also specify that we want the context server to return the values of the tweetNb and tweetedFrom profile properties in its response. Finally, we provide a custom event of type tweetEvent with associated scope and source information, which matches the source of our context request in this case.

The tweetEvent event type is not defined by default in jCustomer. This is where our jCustomer plugin comes into play since we need to tell jCustomer how to react when it encounters such events.

jCustomer plugin overview

As previously mentioned, the jCustomer plugin can be found at https://github.com/metacosm/unomi-tweet-button-plugin/tree/with-mf-1_0_0 (with-mf-1_0_0 branch).

In order to react to tweetEvent events, we will define a new jCustomer rule since this is exactly what jCustomer rules are supposed to do. Rules are guarded by conditions and if these conditions match, the associated set of actions will be executed. In our case, we want our new rule (incrementTweetNumber) to only react to tweetEvent events and we want it to perform the profile update accordingly: create the property types for our custom properties if they don't exist and update them. To do so, we will create a custom action (incrementTweetNumberAction) that will be triggered any time our rule matches. An action is some custom code that is deployed in the context server and can access the jCustomer API to perform what it is that it needs to do.

Plugin architecture

jCustomer is architected so that users can provide extensions in the form of plugins. Being built on top of Apache Karaf, jCustomer leverages OSGi to support plugins. A jCustomer plugin is, thus, an OSGi bundle specifying some specific metadata to tell jCustomer the kind of entities it provides. A plugin can provide the following entity types to extend jCustomer, each with its associated definition (as a JSON file), located in a specific spot within the META-INF/cxs/ directory of the bundle JAR file:

Entity type Description Location in cxs directory
Action Consequences triggered when rules are matched actions
Condition Expression destined at performing tests on items conditions
Persona Virtual profile with determined properties to test a site against personas
Property Profile/session property definition with associated category properties then profiles or sessions subdirectory then <category name> directory
Rule Conditional set of actions to be performed in response to events rules
Scoring Set of conditions associated with a value to assign to profiles when matching scorings
Segments Conditions against which profiles are evaluated to categorize them segments
Tag Tags that can be used to categorize items tags then <category name>
Value Definition for values that can be assigned to properties ("primitive " types) values
Property merge strategy Strategy to resolve how to merge properties when performing profile merges mergers

Blueprint is used to declare what the plugin provides and inject any required dependency. The Blueprint file is located, as usual, at OSGI-INF/blueprint/blueprint.xml in the bundle JAR file.

The plugin otherwise follows a regular maven project layout and must use the following parent in its POM file, using the appropriate jCustomer version:

<parent>
    <groupId>org.oasis-open.contextserver</groupId>
    <artifactId>context-server-plugins</artifactId>
    <version>...</version>
</parent>

Rule definition

Let's look at how our custom (incrementTweetNumber) rule is defined:

{
  "metadata": {
    "id": "smp:incrementTweetNumber",
    "name": "Increment tweet number",
    "description": "Increments the number of times a user has tweeted after they click on a tweet button"
  },
  "raiseEventOnlyOnceForSession": false,
  "condition": {
    "type": "eventTypeCondition",
    "parameterValues": {
      "eventTypeId": "tweetEvent"
    }
  },
  "actions": [
    {
      "type": "incrementTweetNumberAction",
      "parameterValues": {}
    }
  ]
}

Rules define a metadata section where we specify the rule name, identifier and description.

When rules trigger, a specific event is raised so that other parts of jCustomer can react to it accordingly. We can control how that event should be raised. Here we specify that the event should be raised each time the rule triggers and not only once per session by setting raiseEventOnlyOnceForSession to false, which is not strictly required since that is the default. A similar setting (raiseEventOnlyOnceForProfile) can be used to specify that the event should only be raised once per profile if needed.

We could also specify a priority for our rule in case it needs to be executed before other ones when similar conditions match. This is accomplished using the priority property. We're using the default priority here since we don't have other rules triggering on tweetEvents and don't need any special ordering.

We then tell jCustomer which condition should trigger the rule via the condition property. Here, we specify that we want our rule to trigger on an eventTypeCondition condition. As seen earlier, jCustomer can be extended by adding new condition types that can enrich how matching or querying is performed in jCustomer. The condition type definition file specifies which parameters are expected for our condition to be complete. In our case, we use the built-in event type condition that will match if jCustomer receives an event of the type specified in the condition's eventTypeId parameter value: tweetEvent here.

Finally, we specify a list of actions that should be performed as consequences of the rule matching. We only need one action of type incrementTweetNumberAction that doesn't require any parameters.

Action definition

Let's now look at our custom incrementTweetNumberAction action type definition:

{
  "id": "incrementTweetNumberAction",
  "actionExecutor": "incrementTweetNumber",
  "tags": [
    "event"
  ],
  "parameters": []
}

We specify the identifier for the action type, a list of tags if needed: here we say that our action is a consequence of events using the event tag. Our actions does not require any parameters so we don't define any.

Finally, we provide a mysterious actionExecutor identifier: incrementTweetNumber.

Action executor definition

The action executor references the actual implementation of the action as defined in our blueprint definition:

<blueprint xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xmlns="http://www.osgi.org/xmlns/blueprint/v1.0.0"
           xsi:schemaLocation="http://www.osgi.org/xmlns/blueprint/v1.0.0 http://www.osgi.org/xmlns/blueprint/v1.0.0/blueprint.xsd">

    <reference id="profileService" interface="org.oasis_open.contextserver.api.services.ProfileService"/>

    <!-- Action executor -->
    <service id="incrementTweetNumberAction" auto-export="interfaces">
        <service-properties>
            <entry key="actionExecutorId" value="incrementTweetNumber"/>
        </service-properties>
        <bean class="org.jahia.modules.unomi_tweet_button_plugin.actions.IncrementTweetNumberAction">
            <property name="profileService" ref="profileService"/>
        </bean>
    </service>
</blueprint>

In standard Blueprint fashion, we specify that we will need the profileService defined by jCustomer and then define a service of our own to be exported for jCustomer to use. Our service specifies one property: actionExecutorId which matches the identifier we specified in our action definition. We then inject the profile service in our executor and we're done for the configuration side of things!

Action executor implementation

Our action executor definition specifies that the bean providing the service is implemented in the org.jahia.modules.unomi_tweet_button_plugin.actions.IncrementTweetNumberAction class. This class implements the jCustomer ActionExecutor interface which provides a single int execute(Action action, Event event) method: the executor gets the action instance to execute along with the event that triggered it, performs its work and return an integer status corresponding to what happened as defined by public constants of the EventService interface of jCustomer: NO_CHANGE, SESSION_UPDATED or PROFILE_UPDATED.

Let's now look at the implementation of the method:

final Profile profile = event.getProfile();
Integer tweetNb = (Integer) profile.getProperty(TWEET_NB_PROPERTY);
List<String> tweetedFrom = (List<String>) profile.getProperty(TWEETED_FROM_PROPERTY);

if (tweetNb == null || tweetedFrom == null) {
// create tweet number property type
    PropertyType propertyType = new PropertyType(new Metadata(event.getScope(), TWEET_NB_PROPERTY, TWEET_NB_PROPERTY, "Number of times a user tweeted"));
    propertyType.setValueTypeId("integer");
    service.createPropertyType(propertyType);

// create tweeted from property type
    propertyType = new PropertyType(new Metadata(event.getScope(), TWEETED_FROM_PROPERTY, TWEETED_FROM_PROPERTY, "The list of pages a user tweeted from"));
    propertyType.setValueTypeId("string");
    propertyType.setMultivalued(true);
    service.createPropertyType(propertyType);

    tweetNb = 0;
    tweetedFrom = new ArrayList<>();
}

profile.setProperty(TWEET_NB_PROPERTY, tweetNb + 1);
final String sourceURL = extractSourceURL(event);
if (sourceURL != null) {
    tweetedFrom.add(sourceURL);
}
profile.setProperty(TWEETED_FROM_PROPERTY, tweetedFrom);

return EventService.PROFILE_UPDATED;

It is fairly straightforward: we retrieve the profile associated with the event that triggered the rule and check whether it already has the properties we are interested in. If not, we create the associated property types and initialize the property values.

Note that it is not an issue to attempt to create the same property type multiple times as jCustomer will not add a new property type if an identical type already exists.

Once this is done, we update our profile with the new property values based on the previous values and the metadata extracted from the event. We then return that the profile was updated as a result of our action and jCustomer will properly save it for us when appropriate. That's it!

Deployment and custom definition

When you deploy a custom bundle in jCustomer or a custom module in Jahia with a custom definition for the first time, the definition it carries will be deployed at your bundle/module start event if it does not exist, after that if you redeploy the same bundle/module there are three cases:

  1. Your bundle/module is a SNAPSHOT then every time you redeploy it the definition will be redeployed
  2. Your bundle (Unomi) is NOT a SNAPSHOT then the definition will not be redeployed, but you can redeploy it manually by connecting to the jCustomer console (using ssh) and using the command unomi:deploy-definition <bundleId> <fileName>
  3. Your module (Jahia) is NOT a SNAPSHOT then the definition will not be redeployed and there is no way to redeploy it manually as of today.