Frontend user synchronisation 

The commands academic:createprofiles and academic:updateprofiles copy fe_users columns onto the profile and onto one contract of it, the imported contract. Which column feeds which property is the frontendUserSync map of Configuration/AcademicPersons/Settings.yaml.

Neither command changes whether a profile is shown. Hiding or deleting the profiles of disabled and deleted frontend users is the job of academic:cleanupprofiles , see Profile cleanup.

The shipped map 

EXT:academic_persons/Configuration/AcademicPersons/Settings.yaml
frontendUserSync:
  profile:
    title: title
    firstName: first_name
    middleName: middle_name
    lastName: last_name
    website: www
  contract:
    position: ''
    room: ''
    organisationalUnit:
      column: ''
      matchBy: uniqueName
      create: false
      storagePid: 0
    functionType:
      column: ''
      matchBy: functionName
      create: false
      storagePid: 0
  physicalAddresses:
    - street: address
      zip: zip
      city: city
      country: country
  emailAddresses:
    - column: email
  phoneNumbers:
    - column: telephone
      type: ''
    - column: fax
      type: ''
Copied!

It is the mapping the synchronisation followed before the map existed, record for record: an installation that does not ship a map of its own gets the same profiles, contracts, contact records and import identifiers as before.

The keys 

Every value is the name of an fe_users column, or of a value a listener adds to the frontend user data, see Taking part in the frontend user synchronisation. A property mapped to '' or ~ is not synchronised: the synchronisation neither writes nor clears it, and the value an editor entered stays. A mapped property is written on every run, and an empty column clears it.

Key Maps
profile Profile properties: title , firstName , middleName , lastName , website , websiteTitle , publicationsLink , publicationsLinkTitle , coreCompetences , miscellaneous , supervisedThesis , supervisedDoctoralThesis and teachingArea .
contract Properties of the imported contract: position and room , and its relations organisationalUnit and functionType , each a map of its own, see Organisational unit and function type.
physicalAddresses A list. Each entry is one address and maps street , streetNumber , additional , zip , city , state and country .
emailAddresses A list. Each entry is one e-mail address, from its column .
phoneNumbers A list. Each entry is one phone number, from its column , with a type .

A phone number's type is one of types.phoneNumberTypes. '' takes profile.feuser.faxNumberType for the column fax and profile.feuser.telephoneNumberType for any other column. A type the installation does not offer is stored as the undefined type '', like a configured one.

The gender and the two letters the list navigation files a profile under are not part of the map: the gender is a fixed selection, and the letters are derived from the names.

Organisational unit and function type 

The organisational unit and the function type of the imported contract are records of their own. A column names one of them by a value the record carries, and the synchronisation assigns the record that matches:

Key Meaning
column The fe_users column holding the value. '' or ~: the relation is not synchronised, and an editor's choice stays.
matchBy The field of the record the value is compared with: uniqueName (the default) or unitName for an organisational unit, functionName for a function type.
create true creates a missing record. false, the default, leaves the relation empty instead.
storagePid The page a created record is stored on. Required with create: true , a record is never created on page 0.

A record matches when its field holds exactly the value, after the blanks around the value are removed. Case and accents count on every database, on MySQL and MariaDB too, whose collation would ignore them. Hidden records match, so a unit an editor hid is assigned rather than created a second time, and so do records on any page, whatever storagePid says. Deleted records, drafts of a workspace and translations never match: the value is compared with the default language. When several records match, the one with the lowest uid is assigned, the same one on every database. Keep the values unique to avoid relying on that.

A created organisational unit takes the value as its name, and as its unique name when it is matched by the unique name. A created function type takes it as its name. Both get the URL segment a save would generate from that name. Everything else is left for an editor, including the translations. The record is saved right away, so the next frontend user with the same value, and the next run, find it instead of creating another one. Runs of the two commands in parallel can still create one record twice: run them one after the other. The names hold 255 characters. On PostgreSQL, and on MySQL and MariaDB in their default strict mode, creating a longer value stops the command with a database error. Without strict mode it is cut to 255 characters, never matches again and is created anew on every run. Keep the values shorter.

A mapped relation belongs to the synchronisation. An empty column clears it, and so does a value that matches nothing when create is off. A relation that is not mapped is never touched.

EXT:my_sitepackage/Configuration/AcademicPersons/Settings.yaml
frontendUserSync:
  contract:
    organisationalUnit:
      column: company
      create: true
      storagePid: 42
    functionType:
      column: tx_project_function
Copied!

The employee type is not synchronised, and an editor's choice stays. Its categories carry no type in EXT:academic_persons, so a title can match categories of any purpose. A project that takes the employee type from the frontend user data sets it in a listener of AfterProfileMappedFromFrontendUserEvent , see AfterProfileMappedFromFrontendUserEvent. The listener reads the value from the frontend user data, looks the category up with a query of its own and sets it on the imported contract. The query is ordered, and the title is compared in PHP, because MySQL and MariaDB ignore case and accents when they compare it. That way the same category wins on every database. With EXT:category_types installed, the query can be restricted to one category type:

EXT:my_sitepackage/Classes/EventListener/SetEmployeeType.php
<?php

declare(strict_types=1);

namespace MyVendor\MySitepackage\EventListener;

use FGTCLB\AcademicPersons\Event\AfterProfileMappedFromFrontendUserEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Extbase\Domain\Model\Category;
use TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface;

final readonly class SetEmployeeType
{
    public function __construct(
        private ConnectionPool $connectionPool,
        private PersistenceManagerInterface $persistenceManager,
    ) {}

    #[AsEventListener(identifier: 'my-sitepackage/set-employee-type')]
    public function __invoke(AfterProfileMappedFromFrontendUserEvent $event): void
    {
        $frontendUserData = $event->getFrontendUserData();
        $title = trim((string)($frontendUserData['tx_project_employee_type'] ?? ''));
        $category = $title === '' ? null : $this->findCategory($title);
        $importIdentifier = 'fe_users:' . $frontendUserData['uid'];
        foreach ($event->getProfile()->getContracts() as $contract) {
            if ($contract->getImportIdentifier() === $importIdentifier) {
                $contract->setEmployeeType($category);
            }
        }
    }

    private function findCategory(string $title): ?Category
    {
        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
        $result = $queryBuilder
            ->select('uid', 'title')
            ->from('sys_category')
            ->where(
                $queryBuilder->expr()->eq('title', $queryBuilder->createNamedParameter($title)),
                // A category type the project registers, EXT:category_types only.
                $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('employee_type')),
                $queryBuilder->expr()->in(
                    'sys_language_uid',
                    $queryBuilder->quoteArrayBasedValueListToIntegerList([0, -1]),
                ),
                $queryBuilder->expr()->eq('t3ver_wsid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)),
            )
            ->orderBy('uid')
            ->executeQuery();
        while ($row = $result->fetchAssociative()) {
            if ($row['title'] === $title) {
                $category = $this->persistenceManager->getObjectByIdentifier((int)$row['uid'], Category::class);
                return $category instanceof Category ? $category : null;
            }
        }
        return null;
    }
}
Copied!

Like the relations above, the listener then owns the employee type of the imported contract: it clears it where the column is empty or names no category.

The records of a list 

Every entry of a list is one record of the imported contract, identified by an import identifier:

  • The first address and the first e-mail address: fe_users:<uid>, the identifier they had before the lists existed.
  • Every further address and e-mail address, and every phone number: <first column>:fe_users:<uid>, for instance tx_project_mobile:fe_users:42.

The synchronisation finds a record by that identifier, hidden ones included, updates it and never changes its visibility. It creates the record when it is missing and removes it when every column of its entry is empty. Records an editor added carry no import identifier and are never touched.

The backend form of a record shows its identifier read-only in the palette Import of the tab Extended. On a profile the palette also holds Disable profile sync. A record without an identifier shows neither. The list module and the backend search find a profile by its identifier. How import code of a project finds a record by it, and how the contact records become searchable, is described in Finding what an import wrote.

The identifier follows the map, not the data:

  • Moving another entry to the front of the address or e-mail list makes it write its data onto the record fe_users:<uid>. The moved entry imports a new record, and the one it wrote before is no longer synchronised: it stays as it is and is never removed by the synchronisation again.
  • Changing the first column of an entry identified by it imports a new record in the same way.

An entry has to map at least one column. To stop synchronising a whole list, set it to [].

The imported contract itself exists for as long as one of its mapped sources - a contract property, a relation or a column of any entry - is set. When all of them are empty, academic:updateprofiles removes the contract, together with every address, e-mail address and phone number of it, the ones an editor added included; academic:createprofiles creates it with every new profile, as it did before the map. A map that names no source of the contract at all - no contract property, no relation and three empty lists - does not synchronise the contract: it is neither created nor removed nor written.

Changing the map 

A site package ships its own Configuration/AcademicPersons/Settings.yaml and states what it changes. Maps are merged key by key; a list is replaced as a whole, so a package adding a phone number repeats the shipped ones:

EXT:my_sitepackage/Configuration/AcademicPersons/Settings.yaml
frontendUserSync:
  profile:
    # Editors maintain the website, the synchronisation leaves it alone.
    website: ''
  contract:
    position: tx_project_position
  phoneNumbers:
    - column: telephone
      type: ''
    - column: fax
      type: ''
    - column: tx_project_mobile
      type: mobile
Copied!

The columns are not created by this map; they are columns of fe_users the installation already has, for instance filled by an LDAP import, or values a listener adds to the frontend user data while the synchronisation runs, see Taking part in the frontend user synchronisation. Flush the TYPO3 caches after changing the map.

A mistake in the map - an unknown property, a value that is not a string, a list where a map belongs, an entry that maps no column, two entries of one list named by the same first column, a phone number read from the column phone , whose identifier is the one of the telephone records written before 2.4, a relation matched by a field it does not offer, or one that creates records without a storagePid - does not break the site. The synchronisation refuses to run on it: the default profile factory, and every factory using the mapper below, throws an exception with the code 1790142324 before anything is written, and its message names every mistake with its path.

The map cannot tell a column name from a typo. The default profile factory therefore also compares it with the frontend user record it synchronises and refuses a column the record does not have, with the code 1790142326. A misspelled column would otherwise read as empty on every run and remove the records it imported.

Custom profile factories 

A profile factory that reads its data from another source - an LDAP directory, an HR system - can apply the same map instead of copying it. It injects \FGTCLB\AcademicPersons\Profile\FrontendUserProfileMapper and passes an array keyed like an fe_users record, with its uid :

applyProfile(array $frontendUserData, Profile $profile)
Writes the mapped profile properties.
assertColumnsExist(array $frontendUserData)
Refuses data that lacks its uid or a column the map reads. A factory whose data may lack a column - an LDAP entry omits empty attributes - does not call it, and the column reads as empty.
mapsContract()
Whether the map names any source of the imported contract. Without one, leave the contract alone.
hasContractData(array $frontendUserData)
Whether one mapped contract source is set.
applyContract(array $frontendUserData, Contract $contract, int $pid)
Writes the mapped contract properties and relations, and synchronises the addresses, e-mail addresses and phone numbers of the contract. When it creates an organisational unit or function type, it saves everything the persistence manager holds at that moment. A factory that adds its profile before calling it gets the profile saved half-written, and completed when it saves at the end.

Creating the profile and the imported contract, and removing that contract, stays with the factory, as \FGTCLB\AcademicPersons\Profile\ProfileFactory shows.

A factory extending \FGTCLB\AcademicPersons\Profile\AbstractProfileFactory dispatches the events of Taking part in the frontend user synchronisation without any code of its own. Its createProfileFromFrontendUser() may return null when it has no profile for a frontend user, for example when the directory it reads does not know the user. academic:createprofiles then saves nothing for that frontend user, announces nothing and goes on with the next one. A factory that always creates a profile may keep Profile as its return type.

Before a factory of its own, check whether a listener is enough: a factory replaces the default one as a whole, and does not pick up later fixes to it.