Frontend user synchronisation
The commands
academic: and
academic: copy
fe_ columns onto the profile
and onto one contract of it, the imported contract. Which column feeds which
property is the
frontend map of
Configuration/.
Neither command changes whether a profile is shown. Hiding or deleting the
profiles of disabled and deleted frontend users is the job of
academic:, see Profile cleanup.
The shipped map
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: ''
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_ 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
,
first,
middle,
last,
website
,
website,
publications,
publications,
core,
miscellaneous
,
supervised,
supervised and
teaching. |
contract
| Properties of the imported contract:
position
and
room
, and its relations
organisational and
function, each a map of its own, see
Organisational unit and function type. |
physical | A list. Each entry is one address and maps
street
,
street,
additional
,
zip
,
city
,
state
and
country
. |
email | A list. Each entry is one e-mail address, from its
column
. |
phone | 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_ column holding the value. '' or ~: the
relation is not synchronised, and an editor's choice stays. |
match | The field of the record the value is compared with:
unique (the default) or
unit for an
organisational unit,
function for a function type. |
create
| true creates a missing record. false, the default, leaves
the relation empty instead. |
storage | 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
storage 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.
frontendUserSync:
contract:
organisationalUnit:
column: company
create: true
storagePid: 42
functionType:
column: tx_project_function
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
After, 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:
<?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;
}
}
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_, the identifier they had before the lists existed.users:<uid> - Every further address and e-mail address, and every phone number:
<first column>:, for instancefe_ users:<uid> 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_. 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.users:<uid> - 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: removes the contract, together
with every address, e-mail address and phone number of it, the ones an editor
added included;
academic: 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/
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:
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
The columns are not created by this map; they are columns of
fe_
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
storage - 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\ and passes an
array keyed like an
fe_ record, with its
uid
:
applyProfile (array $frontend User Data, Profile $profile) - Writes the mapped profile properties.
assertColumns Exist (array $frontend User Data) - Refuses data that lacks its
uidor 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.
hasContract Data (array $frontend User Data) - Whether one mapped contract source is set.
applyContract (array $frontend User Data, 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\
shows.
A factory extending
\FGTCLB\
dispatches the events of Taking part in the frontend user synchronisation without
any code of its own. Its
create 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: 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.