Skip to content

Part 5: Person Information#

This part of our tutorial series lays the foundation for future parts in which we will be using additional APIs, which we have not used in this series yet. To make use of those APIs, we need content generated by users in the frontend.

Package Functionality#

In addition to the existing functions from part 4, the package will provide the following functionality after this part of the tutorial:

  • Users are able to add information on the people in the frontend.
  • Users are able to edit and delete the pieces of information they added.
  • Moderators are able to edit and delete all pieces of information.

Used Components#

In addition to the components used in previous parts, we will use the form builder API to create forms shown in dialogs instead of dedicated pages and we will, for the first time, add TypeScript code.

Package Structure#

The package will have the following file structure excluding unchanged files from previous parts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
├── files
│   ├── acp
│   │   └── database
│   │       └── install_com.woltlab.wcf.people.php
│   ├── js
│   │   └── WoltLabSuite
│   │       └── Core
│   │           └── Controller
│   │               └── Person.js
│   └── lib
│       ├── data
│       │   └── person
│       │       ├── Person.class.php
│       │       └── information
│       │           ├── PersonInformation.class.php
│       │           ├── PersonInformationAction.class.php
│       │           ├── PersonInformationEditor.class.php
│       │           └── PersonInformationList.class.php
│       └── system
│           └── worker
│               └── PersonRebuildDataWorker.class.php
├── language
│   ├── de.xml
│   └── en.xml
├── objectType.xml
├── templates
│   ├── person.tpl
│   └── personList.tpl
├── ts
│   └── WoltLabSuite
│       └── Core
│           └── Controller
│               └── Person.ts
└── userGroupOption.xml

For all changes, please refer to the source code on GitHub.

Miscellaneous#

Before we focus on the main aspects of this part, we mention some minor aspects that will be used later on:

  • Several new user group options and the relevant language items have been added related to creating, editing, and deleting information:
    • mod.person.canEditInformation and mod.person.canDeleteInformation are moderative permissions to edit and delete any piece of information, regardless of who created it.
    • user.person.canAddInformation is the permission for users to add new pieces of information.
    • user.person.canEditInformation and user.person.canDeleteInformation are the user permissions to edit and the piece of information they created.
  • The actual information text will be entered via a WYSIWYG editor, which requires an object type of the definition com.woltlab.wcf.message: com.woltlab.wcf.people.information.
  • personList.tpl has been adjusted to show the number of pieces of information in the person statistics section.
  • We have not updated the person list box to also support sorting by the number of pieces of information added for each person.

Person Information Model#

The PHP file with the database layout has been updated as follows:

files/acp/database/install_com.woltlab.wcf.people.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<?php

use wcf\system\database\table\column\DefaultTrueBooleanDatabaseTableColumn;
use wcf\system\database\table\column\IntDatabaseTableColumn;
use wcf\system\database\table\column\NotNullInt10DatabaseTableColumn;
use wcf\system\database\table\column\NotNullVarchar255DatabaseTableColumn;
use wcf\system\database\table\column\ObjectIdDatabaseTableColumn;
use wcf\system\database\table\column\SmallintDatabaseTableColumn;
use wcf\system\database\table\column\TextDatabaseTableColumn;
use wcf\system\database\table\column\VarcharDatabaseTableColumn;
use wcf\system\database\table\DatabaseTable;
use wcf\system\database\table\index\DatabaseTableForeignKey;
use wcf\system\database\table\index\DatabaseTablePrimaryIndex;

return [
    DatabaseTable::create('wcf1_person')
        ->columns([
            ObjectIdDatabaseTableColumn::create('personID'),
            NotNullVarchar255DatabaseTableColumn::create('firstName'),
            NotNullVarchar255DatabaseTableColumn::create('lastName'),
            NotNullInt10DatabaseTableColumn::create('informationCount')
                ->defaultValue(0),
            SmallintDatabaseTableColumn::create('comments')
                ->length(5)
                ->notNull()
                ->defaultValue(0),
            DefaultTrueBooleanDatabaseTableColumn::create('enableComments'),
        ])
        ->indices([
            DatabaseTablePrimaryIndex::create()
                ->columns(['personID']),
        ]),

    DatabaseTable::create('wcf1_person_information')
        ->columns([
            ObjectIdDatabaseTableColumn::create('informationID'),
            NotNullInt10DatabaseTableColumn::create('personID'),
            TextDatabaseTableColumn::create('information'),
            IntDatabaseTableColumn::create('userID')
                ->length(10),
            NotNullVarchar255DatabaseTableColumn::create('username'),
            VarcharDatabaseTableColumn::create('ipAddress')
                ->length(39)
                ->notNull(true)
                ->defaultValue(''),
            NotNullInt10DatabaseTableColumn::create('time'),
        ])
        ->indices([
            DatabaseTablePrimaryIndex::create()
                ->columns(['informationID']),
        ])
        ->foreignKeys([
            DatabaseTableForeignKey::create()
                ->columns(['personID'])
                ->referencedTable('wcf1_person')
                ->referencedColumns(['personID'])
                ->onDelete('CASCADE'),
            DatabaseTableForeignKey::create()
                ->columns(['userID'])
                ->referencedTable('wcf1_user')
                ->referencedColumns(['userID'])
                ->onDelete('SET NULL'),
        ]),
];
  • The number of pieces of information per person is tracked via the new informationCount column.
  • The wcf1_person_information table has been added for the PersonInformation model. The meaning of the different columns is explained in the property documentation part of PersonInformation's documentation (see below). The two foreign keys ensure that if a person is deleted, all of their information is also deleted, and that if a user is deleted, the userID column is set to NULL.
files/lib/data/person/information/PersonInformation.class.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
<?php

namespace wcf\data\person\information;

use wcf\data\DatabaseObject;
use wcf\data\person\Person;
use wcf\data\user\UserProfile;
use wcf\system\cache\runtime\PersonRuntimeCache;
use wcf\system\cache\runtime\UserProfileRuntimeCache;
use wcf\system\html\output\HtmlOutputProcessor;
use wcf\system\WCF;

/**
 * Represents a piece of information for a person.
 *
 * @author  Matthias Schmidt
 * @copyright   2001-2021 WoltLab GmbH
 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 * @package WoltLabSuite\Core\Data\Person\Information
 *
 * @property-read   int         $informationID  unique id of the information
 * @property-read   int         $personID       id of the person the information belongs to
 * @property-read   string      $information    information text
 * @property-read   int|null    $userID         id of the user who added the information or `null` if the user no longer exists
 * @property-read   string      $username       name of the user who added the information
 * @property-read   int         $time           timestamp at which the information was created
 */
class PersonInformation extends DatabaseObject
{
    /**
     * Returns `true` if the active user can delete this piece of information and `false` otherwise.
     */
    public function canDelete(): bool
    {
        if (
            WCF::getUser()->userID
            && WCF::getUser()->userID == $this->userID
            && WCF::getSession()->getPermission('user.person.canDeleteInformation')
        ) {
            return true;
        }

        return WCF::getSession()->getPermission('mod.person.canDeleteInformation');
    }

    /**
     * Returns `true` if the active user can edit this piece of information and `false` otherwise.
     */
    public function canEdit(): bool
    {
        if (
            WCF::getUser()->userID
            && WCF::getUser()->userID == $this->userID
            && WCF::getSession()->getPermission('user.person.canEditInformation')
        ) {
            return true;
        }

        return WCF::getSession()->getPermission('mod.person.canEditInformation');
    }

    /**
     * Returns the formatted information.
     */
    public function getFormattedInformation(): string
    {
        $processor = new HtmlOutputProcessor();
        $processor->process(
            $this->information,
            'com.woltlab.wcf.people.information',
            $this->informationID
        );

        return $processor->getHtml();
    }

    /**
     * Returns the person the information belongs to.
     */
    public function getPerson(): Person
    {
        return PersonRuntimeCache::getInstance()->getObject($this->personID);
    }

    /**
     * Returns the user profile of the user who added the information.
     */
    public function getUserProfile(): UserProfile
    {
        if ($this->userID) {
            return UserProfileRuntimeCache::getInstance()->getObject($this->userID);
        } else {
            return UserProfile::getGuestUserProfile($this->username);
        }
    }
}

PersonInformation provides two methods, canDelete() and canEdit(), to check whether the active user can delete or edit a specific piece of information. In both cases, it is checked if the current user has created the relevant piece of information to check the user-specific permissions or to fall back to the moderator-specific permissions.

There also two getter methods for the person, the piece of information belongs to (getPerson()), and for the user profile of the user who created the information (getUserProfile()). In both cases, we use runtime caches, though in getUserProfile(), we also have to consider the case of the user who created the information being deleted, i.e. userID being null. For such a case, we also save the name of the user who created the information in username, so that we can return a guest user profile object in this case. The most interesting method is getFormattedInformation(), which returns the HTML code of the information text meant for output. To generate such an output, HtmlOutputProcessor::process() is used and here is where we first use the associated message object type com.woltlab.wcf.people.information mentioned before.

While PersonInformationEditor is simply the default implementation and thus not explicitly shown here, PersonInformationList::readObjects() caches the relevant ids of the associated people and users who created the pieces of information using runtime caches:

files/lib/data/person/information/PersonInformationList.class.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php

namespace wcf\data\person\information;

use wcf\data\DatabaseObjectList;
use wcf\system\cache\runtime\PersonRuntimeCache;
use wcf\system\cache\runtime\UserProfileRuntimeCache;

/**
 * Represents a list of person information.
 *
 * @author  Matthias Schmidt
 * @copyright   2001-2021 WoltLab GmbH
 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 * @package WoltLabSuite\Core\Data\PersonInformation
 *
 * @method      PersonInformation       current()
 * @method      PersonInformation[]     getObjects()
 * @method      PersonInformation|null  search($objectID)
 * @property    PersonInformation[]     $objects
 */
class PersonInformationList extends DatabaseObjectList
{
    public function readObjects()
    {
        parent::readObjects();

        UserProfileRuntimeCache::getInstance()->cacheObjectIDs(\array_unique(\array_filter(\array_column(
            $this->objects,
            'userID'
        ))));
        PersonRuntimeCache::getInstance()->cacheObjectIDs(\array_unique(\array_column(
            $this->objects,
            'personID'
        )));
    }
}

Listing and Deleting Person Information#

The person.tpl template has been updated to include a block for listing the information at the beginning:

templates/person.tpl
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
{capture assign='pageTitle'}{$person} - {lang}wcf.person.list{/lang}{/capture}

{capture assign='contentTitle'}{$person}{/capture}

{include file='header'}

{if $person->informationCount || $__wcf->session->getPermission('user.person.canAddInformation')}
    <section class="section sectionContainerList">
        <header class="sectionHeader">
            <h2 class="sectionTitle">
                {lang}wcf.person.information.list{/lang}
                {if $person->informationCount}
                    <span class="badge">{#$person->informationCount}</span>
                {/if}
            </h2>
        </header>

        <ul class="commentList containerList personInformationList jsObjectActionContainer" {*
            *}data-object-action-class-name="wcf\data\person\information\PersonInformationAction"{*
        *}>
            {if $__wcf->session->getPermission('user.person.canAddInformation')}
                <li class="containerListButtonGroup">
                    <ul class="buttonGroup">
                        <li>
                            <a href="#" class="button" id="personInformationAddButton">
                                <span class="icon icon16 fa-plus"></span>
                                <span>{lang}wcf.person.information.add{/lang}</span>
                            </a>
                        </li>
                    </ul>
                </li>
            {/if}

            {foreach from=$person->getInformation() item=$information}
                <li class="comment personInformation jsObjectActionObject" data-object-id="{@$information->getObjectID()}">
                    <div class="box48{if $__wcf->getUserProfileHandler()->isIgnoredUser($information->userID, 2)} ignoredUserContent{/if}">
                        {user object=$information->getUserProfile() type='avatar48' ariaHidden='true' tabindex='-1'}

                        <div class="commentContentContainer">
                            <div class="commentContent">
                                <div class="containerHeadline">
                                    <h3>
                                        {if $information->userID}
                                            {user object=$information->getUserProfile()}
                                        {else}
                                            <span>{$information->username}</span>
                                        {/if}

                                        <small class="separatorLeft">{@$information->time|time}</small>
                                    </h3>
                                </div>

                                <div class="htmlContent userMessage" id="personInformation{@$information->getObjectID()}">
                                    {@$information->getFormattedInformation()}
                                </div>

                                <nav class="jsMobileNavigation buttonGroupNavigation">
                                    <ul class="buttonList iconList">
                                        {if $information->canEdit()}
                                            <li class="jsOnly">
                                                <a href="#" title="{lang}wcf.global.button.edit{/lang}" class="jsEditInformation jsTooltip">
                                                    <span class="icon icon16 fa-pencil"></span>
                                                    <span class="invisible">{lang}wcf.global.button.edit{/lang}</span>
                                                </a>
                                            </li>
                                        {/if}
                                        {if $information->canDelete()}
                                            <li class="jsOnly">
                                                <a href="#" title="{lang}wcf.global.button.delete{/lang}" class="jsObjectAction jsTooltip" data-object-action="delete" data-confirm-message="{lang}wcf.person.information.delete.confirmMessage{/lang}">
                                                    <span class="icon icon16 fa-times"></span>
                                                    <span class="invisible">{lang}wcf.global.button.edit{/lang}</span>
                                                </a>
                                            </li>
                                        {/if}

                                        {event name='informationOptions'}
                                    </ul>
                                </nav>
                            </div>
                        </div>
                    </div>
                </li>
            {/foreach}
        </ul>
    </section>
{/if}

{if $person->enableComments}
    {if $commentList|count || $commentCanAdd}
        <section id="comments" class="section sectionContainerList">
            <header class="sectionHeader">
                <h2 class="sectionTitle">
                    {lang}wcf.person.comments{/lang}
                    {if $person->comments}<span class="badge">{#$person->comments}</span>{/if}
                </h2>
            </header>

            {include file='__commentJavaScript' commentContainerID='personCommentList'}

            <div class="personComments">
                <ul id="personCommentList" class="commentList containerList" {*
                    *}data-can-add="{if $commentCanAdd}true{else}false{/if}" {*
                    *}data-object-id="{@$person->personID}" {*
                    *}data-object-type-id="{@$commentObjectTypeID}" {*
                    *}data-comments="{if $person->comments}{@$commentList->countObjects()}{else}0{/if}" {*
                    *}data-last-comment-time="{@$lastCommentTime}" {*
                *}>
                    {include file='commentListAddComment' wysiwygSelector='personCommentListAddComment'}
                    {include file='commentList'}
                </ul>
            </div>
        </section>
    {/if}
{/if}

<footer class="contentFooter">
    {hascontent}
        <nav class="contentFooterNavigation">
            <ul>
                {content}{event name='contentFooterNavigation'}{/content}
            </ul>
        </nav>
    {/hascontent}
</footer>

<script data-relocate="true">
    require(['Language', 'WoltLabSuite/Core/Controller/Person'], (Language, ControllerPerson) => {
        Language.addObject({
            'wcf.person.information.add': '{jslang}wcf.person.information.add{/jslang}',
            'wcf.person.information.add.success': '{jslang}wcf.person.information.add.success{/jslang}',
            'wcf.person.information.edit': '{jslang}wcf.person.information.edit{/jslang}',
            'wcf.person.information.edit.success': '{jslang}wcf.person.information.edit.success{/jslang}',
        });

        ControllerPerson.init({@$person->personID}, {
            canAddInformation: {if $__wcf->session->getPermission('user.person.canAddInformation')}true{else}false{/if},
        });
    });
</script>

{include file='footer'}

To keep things simple here, we reuse the structure and CSS classes used for comments. Additionally, we always list all pieces of information. If there are many pieces of information, a nicer solution would be a pagination or loading more pieces of information with JavaScript.

First, we note the jsObjectActionContainer class in combination with the data-object-action-class-name attribute, which are needed for the delete button for each piece of information, as explained here. In PersonInformationAction, we have overridden the default implementations of validateDelete() and delete() which are called after clicking on a delete button. In validateDelete(), we call PersonInformation::canDelete() on all pieces of information to be deleted for proper permission validation, and in delete(), we update the informationCount values of the people the deleted pieces of information belong to (see below).

The button to add a new piece of information, #personInformationAddButton, and the buttons to edit existing pieces of information, .jsEditInformation, are controlled with JavaScript code initialized at the very end of the template.

Lastly, in create() we provide default values for the time, userID, username, and ipAddress for cases like here when creating a new piece of information, where do not explicitly provide this data. Additionally, we extract the information text from the information_htmlInputProcessor parameter provided by the associated WYSIWYG form field and update the number of pieces of information created for the relevant person.

Creating and Editing Person Information#

To create new pieces of information or editing existing ones, we do not add new form controllers but instead use dialogs generated by the form builder API so that the user does not have to leave the person page.

When clicking on the add button or on any of the edit buttons, a dialog opens with the relevant form:

ts/WoltLabSuite/Core/Controller/Person.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
/**
 * Provides the JavaScript code for the person page.
 *
 * @author  Matthias Schmidt
 * @copyright  2001-2021 WoltLab GmbH
 * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 * @module  WoltLabSuite/Core/Controller/Person
 */

import FormBuilderDialog from "WoltLabSuite/Core/Form/Builder/Dialog";
import * as Language from "WoltLabSuite/Core/Language";
import * as UiNotification from "WoltLabSuite/Core/Ui/Notification";

let addDialog: FormBuilderDialog;
const editDialogs = new Map<string, FormBuilderDialog>();

interface EditReturnValues {
  formattedInformation: string;
  informationID: number;
}

interface Options {
  canAddInformation: true;
}

/**
 * Opens the edit dialog after clicking on the edit button for a piece of information.
 */
function editInformation(event: Event): void {
  event.preventDefault();

  const currentTarget = event.currentTarget as HTMLElement;
  const information = currentTarget.closest(".jsObjectActionObject") as HTMLElement;
  const informationId = information.dataset.objectId!;

  if (!editDialogs.has(informationId)) {
    editDialogs.set(
      informationId,
      new FormBuilderDialog(
        `personInformationEditDialog${informationId}`,
        "wcf\\data\\person\\information\\PersonInformationAction",
        "getEditDialog",
        {
          actionParameters: {
            informationID: informationId,
          },
          dialog: {
            title: Language.get("wcf.person.information.edit"),
          },
          submitActionName: "submitEditDialog",
          successCallback(returnValues: EditReturnValues) {
            document.getElementById(`personInformation${returnValues.informationID}`)!.innerHTML =
              returnValues.formattedInformation;

            UiNotification.show(Language.get("wcf.person.information.edit.success"));
          },
        },
      ),
    );
  }

  editDialogs.get(informationId)!.open();
}

/**
 * Initializes the JavaScript code for the person page.
 */
export function init(personId: number, options: Options): void {
  if (options.canAddInformation) {
    // Initialize the dialog to add new information.
    addDialog = new FormBuilderDialog(
      "personInformationAddDialog",
      "wcf\\data\\person\\information\\PersonInformationAction",
      "getAddDialog",
      {
        actionParameters: {
          personID: personId,
        },
        dialog: {
          title: Language.get("wcf.person.information.add"),
        },
        submitActionName: "submitAddDialog",
        successCallback() {
          UiNotification.show(Language.get("wcf.person.information.add.success"), () => window.location.reload());
        },
      },
    );

    document.getElementById("personInformationAddButton")!.addEventListener("click", (event) => {
      event.preventDefault();

      addDialog.open();
    });
  }

  document
    .querySelectorAll(".jsEditInformation")
    .forEach((el) => el.addEventListener("click", (ev) => editInformation(ev)));
}

We use the WoltLabSuite/Core/Form/Builder/Dialog module, which takes care of the internal handling with regard to these dialogs. We only have to provide some data during for initializing these objects and call the open() function after a button has been clicked.

Explanation of the initialization arguments for WoltLabSuite/Core/Form/Builder/Dialog used here:

  • The first argument is the id of the dialog used to identify it.
  • The second argument is the PHP class name which provides the contents of the dialog's form and handles the data after the form is submitted.
  • The third argument is the name of the method in the referenced PHP class in the previous argument that returns the dialog form.
  • The fourth argument contains additional options:
    • actionParameters are additional parameters send during each AJAX request. Here, we either pass the id of the person for who a new piece of information is added or the id of the edited piece of information.
    • dialog contains the options for the dialog, see the DialogOptions interface. Here, we only provide the title of the dialog.
    • submitActionName is the name of the method in the referenced PHP class that is called with the form data after submitting the form.
    • successCallback is called after the submit AJAX request was successful. After adding a new piece of information, we reload the page, and after editing an existing piece of information, we update the existing information text with the updated text. (Dynamically inserting a newly added piece of information instead of reloading the page would also be possible, of course, but for this tutorial series, we kept things simple.)

Next, we focus on PersonInformationAction, which actually provides the contents of these dialogs and creates and edits the information:

files/lib/data/person/information/PersonInformationAction.class.php
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
<?php

namespace wcf\data\person\information;

use wcf\data\AbstractDatabaseObjectAction;
use wcf\data\person\PersonAction;
use wcf\data\person\PersonEditor;
use wcf\system\cache\runtime\PersonRuntimeCache;
use wcf\system\event\EventHandler;
use wcf\system\exception\IllegalLinkException;
use wcf\system\exception\PermissionDeniedException;
use wcf\system\exception\UserInputException;
use wcf\system\form\builder\container\wysiwyg\WysiwygFormContainer;
use wcf\system\form\builder\DialogFormDocument;
use wcf\system\html\input\HtmlInputProcessor;
use wcf\system\WCF;
use wcf\util\UserUtil;

/**
 * Executes person information-related actions.
 *
 * @author  Matthias Schmidt
 * @copyright   2001-2021 WoltLab GmbH
 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 * @package WoltLabSuite\Core\Data\Person\Information
 *
 * @method  PersonInformationEditor[]   getObjects()
 * @method  PersonInformationEditor     getSingleObject()
 */
class PersonInformationAction extends AbstractDatabaseObjectAction
{
    /**
     * @var DialogFormDocument
     */
    public $dialog;

    /**
     * @var PersonInformation
     */
    public $information;

    /**
     * @return  PersonInformation
     */
    public function create()
    {
        if (!isset($this->parameters['data']['time'])) {
            $this->parameters['data']['time'] = TIME_NOW;
        }
        if (!isset($this->parameters['data']['userID'])) {
            $this->parameters['data']['userID'] = WCF::getUser()->userID;
            $this->parameters['data']['username'] = WCF::getUser()->username;
        }

        if (LOG_IP_ADDRESS) {
            if (!isset($this->parameters['data']['ipAddress'])) {
                $this->parameters['data']['ipAddress'] = UserUtil::getIpAddress();
            }
        } else {
            unset($this->parameters['data']['ipAddress']);
        }

        if (!empty($this->parameters['information_htmlInputProcessor'])) {
            /** @var HtmlInputProcessor $htmlInputProcessor */
            $htmlInputProcessor = $this->parameters['information_htmlInputProcessor'];
            $this->parameters['data']['information'] = $htmlInputProcessor->getHtml();
        }

        /** @var PersonInformation $information */
        $information = parent::create();

        (new PersonAction([$information->personID], 'update', [
            'counters' => [
                'informationCount' => 1,
            ],
        ]))->executeAction();

        return $information;
    }

    /**
     * @inheritDoc
     */
    public function update()
    {
        if (!empty($this->parameters['information_htmlInputProcessor'])) {
            /** @var HtmlInputProcessor $htmlInputProcessor */
            $htmlInputProcessor = $this->parameters['information_htmlInputProcessor'];
            $this->parameters['data']['information'] = $htmlInputProcessor->getHtml();
        }

        parent::update();
    }

    /**
     * @inheritDoc
     */
    public function validateDelete()
    {
        if (empty($this->objects)) {
            $this->readObjects();

            if (empty($this->objects)) {
                throw new UserInputException('objectIDs');
            }
        }

        foreach ($this->getObjects() as $informationEditor) {
            if (!$informationEditor->canDelete()) {
                throw new PermissionDeniedException();
            }
        }
    }

    /**
     * @inheritDoc
     */
    public function delete()
    {
        $deleteCount = parent::delete();

        if (!$deleteCount) {
            return $deleteCount;
        }

        $counterUpdates = [];
        foreach ($this->getObjects() as $informationEditor) {
            if (!isset($counterUpdates[$informationEditor->personID])) {
                $counterUpdates[$informationEditor->personID] = 0;
            }

            $counterUpdates[$informationEditor->personID]--;
        }

        WCF::getDB()->beginTransaction();
        foreach ($counterUpdates as $personID => $counterUpdate) {
            (new PersonEditor(PersonRuntimeCache::getInstance()->getObject($personID)))->updateCounters([
                'informationCount' => $counterUpdate,
            ]);
        }
        WCF::getDB()->commitTransaction();

        return $deleteCount;
    }

    /**
     * Validates the `getAddDialog` action.
     */
    public function validateGetAddDialog(): void
    {
        WCF::getSession()->checkPermissions(['user.person.canAddInformation']);

        $this->readInteger('personID');
        if (PersonRuntimeCache::getInstance()->getObject($this->parameters['personID']) === null) {
            throw new UserInputException('personID');
        }
    }

    /**
     * Returns the data to show the dialog to add a new piece of information on a person.
     *
     * @return  string[]
     */
    public function getAddDialog(): array
    {
        $this->buildDialog();

        return [
            'dialog' => $this->dialog->getHtml(),
            'formId' => $this->dialog->getId(),
        ];
    }

    /**
     * Validates the `submitAddDialog` action.
     */
    public function validateSubmitAddDialog(): void
    {
        $this->validateGetAddDialog();

        $this->buildDialog();
        $this->dialog->requestData($_POST['parameters']['data'] ?? []);
        $this->dialog->readValues();
        $this->dialog->validate();
    }

    /**
     * Creates a new piece of information on a person after submitting the dialog.
     *
     * @return  string[]
     */
    public function submitAddDialog(): array
    {
        // If there are any validation errors, show the form again.
        if ($this->dialog->hasValidationErrors()) {
            return [
                'dialog' => $this->dialog->getHtml(),
                'formId' => $this->dialog->getId(),
            ];
        }

        (new static([], 'create', \array_merge($this->dialog->getData(), [
            'data' => [
                'personID' => $this->parameters['personID'],
            ],
        ])))->executeAction();

        return [];
    }

    /**
     * Validates the `getEditDialog` action.
     */
    public function validateGetEditDialog(): void
    {
        WCF::getSession()->checkPermissions(['user.person.canAddInformation']);

        $this->readInteger('informationID');
        $this->information = new PersonInformation($this->parameters['informationID']);
        if (!$this->information->getObjectID()) {
            throw new UserInputException('informationID');
        }
        if (!$this->information->canEdit()) {
            throw new IllegalLinkException();
        }
    }

    /**
     * Returns the data to show the dialog to edit a piece of information on a person.
     *
     * @return  string[]
     */
    public function getEditDialog(): array
    {
        $this->buildDialog();
        $this->dialog->updatedObject($this->information);

        return [
            'dialog' => $this->dialog->getHtml(),
            'formId' => $this->dialog->getId(),
        ];
    }

    /**
     * Validates the `submitEditDialog` action.
     */
    public function validateSubmitEditDialog(): void
    {
        $this->validateGetEditDialog();

        $this->buildDialog();
        $this->dialog->updatedObject($this->information, false);
        $this->dialog->requestData($_POST['parameters']['data'] ?? []);
        $this->dialog->readValues();
        $this->dialog->validate();
    }

    /**
     * Updates a piece of information on a person after submitting the edit dialog.
     *
     * @return  string[]
     */
    public function submitEditDialog(): array
    {
        // If there are any validation errors, show the form again.
        if ($this->dialog->hasValidationErrors()) {
            return [
                'dialog' => $this->dialog->getHtml(),
                'formId' => $this->dialog->getId(),
            ];
        }

        (new static([$this->information], 'update', $this->dialog->getData()))->executeAction();

        // Reload the information with the updated data.
        $information = new PersonInformation($this->information->getObjectID());

        return [
            'formattedInformation' => $information->getFormattedInformation(),
            'informationID' => $this->information->getObjectID(),
        ];
    }

    /**
     * Builds the dialog to create or edit person information.
     */
    protected function buildDialog(): void
    {
        if ($this->dialog !== null) {
            return;
        }

        $this->dialog = DialogFormDocument::create('personInformationAddDialog')
            ->appendChild(
                WysiwygFormContainer::create('information')
                    ->messageObjectType('com.woltlab.wcf.people.information')
                    ->required()
            );

        EventHandler::getInstance()->fireAction($this, 'buildDialog');

        $this->dialog->build();
    }
}

When setting up the WoltLabSuite/Core/Form/Builder/Dialog object for adding new pieces of information, we specified getAddDialog and submitAddDialog as the names of the dialog getter and submit handler. In addition to these two methods, the matching validation methods validateGetAddDialog() and validateGetAddDialog() are also added. As the forms for adding and editing pieces of information have the same structure, this form is created in buildDialog() using a DialogFormDocument object, which is intended for forms in dialogs. We fire an event in buildDialog() so that plugins are able to easily extend the dialog with additional data.

validateGetAddDialog() checks if the user has the permission to create new pieces of information and if a valid id for the person, the information will belong to, is given. The method configured in the WoltLabSuite/Core/Form/Builder/Dialog object returning the dialog is expected to return two values: the id of the form (formId) and the contents of form shown in the dialog (dialog). This data is returned by getAddDialog using the dialog build previously by buildDialog().

After the form is submitted, validateSubmitAddDialog() has to do the same basic validation as validateGetAddDialog() so that validateGetAddDialog() is simply called. Additionally, the form data is read and validated. In submitAddDialog(), we first check if there have been any validation errors: If any error occured during validation, we return the same data as in getAddDialog() so that the dialog is shown again with the erroneous fields marked as such. Otherwise, if the validation succeeded, the form data is used to create the new piece of information. In addition to the form data, we manually add the id of the person to whom the information belongs to. Lastly, we could return some data that we could access in the JavaScript callback function after successfully submitting the dialog. As we will simply be reloading the page, no such data is returned. An alternative to reloading to the page would be dynamically inserting the new piece of information in the list so that we would have to return the rendered list item for the new piece of information.

The process for getting and submitting the dialog to edit existing pieces of information is similar to the process for adding new pieces of information. Instead of the id of the person, however, we now pass the id of the edited piece of information and in submitEditDialog(), we update the edited information instead of creating a new one like in submitAddDialog(). After editing a piece of information, we do not reload the page but dynamically update the text of the information in the TypeScript code so that we return the updated rendered information text and id of the edited pieced of information in submitAddDialog().

Rebuild Data Worker#

To ensure the integrity of the person data, PersonRebuildDataWorker updates the informationCount counter:

files/lib/system/worker/PersonRebuildDataWorker.class.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?php

namespace wcf\system\worker;

use wcf\data\person\PersonList;
use wcf\system\WCF;

/**
 * Worker implementation for updating people.
 *
 * @author  Matthias Schmidt
 * @copyright   2001-2021 WoltLab GmbH
 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 * @package WoltLabSuite\Core\System\Worker
 *
 * @method  PersonList  getObjectList()
 */
class PersonRebuildDataWorker extends AbstractRebuildDataWorker
{
    /**
     * @inheritDoc
     */
    protected $limit = 500;

    /**
     * @inheritDoc
     */
    protected $objectListClassName = PersonList::class;

    /**
     * @inheritDoc
     */
    protected function initObjectList()
    {
        parent::initObjectList();

        $this->objectList->sqlOrderBy = 'person.personID';
    }

    /**
     * @inheritDoc
     */
    public function execute()
    {
        parent::execute();

        if (!\count($this->objectList)) {
            return;
        }

        $sql = "UPDATE  wcf" . WCF_N . "_person person
                SET     informationCount = (
                            SELECT  COUNT(*)
                            FROM    wcf" . WCF_N . "_person_information person_information
                            WHERE   person_information.personID = person.personID
                        )
                WHERE   person.personID = ?";
        $statement = WCF::getDB()->prepareStatement($sql);

        WCF::getDB()->beginTransaction();
        foreach ($this->getObjectList() as $person) {
            $statement->execute([$person->personID]);
        }
        WCF::getDB()->commitTransaction();
    }
}

Username and IP Address Event Listeners#

As we store the name of the user who create a new piece of information and store their IP address, we have to add event listeners to properly handle the following scenarios:

  1. If the user is renamed, the value of username stored with the person information has to be updated, which can be achieved by a simple event listener that only has to specify the name of relevant database table if AbstractUserActionRenameListener is extended:
files/lib/system/event/listener/PersonUserActionRenameListener.class.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

namespace wcf\system\event\listener;

/**
 * Updates person information during user renaming.
 *
 * @author  Matthias Schmidt
 * @copyright   2001-2021 WoltLab GmbH
 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 * @package WoltLabSuite\Core\System\Event\Listener
 */
class PersonUserActionRenameListener extends AbstractUserActionRenameListener
{
    /**
     * @inheritDoc
     */
    protected $databaseTables = [
        'wcf{WCF_N}_person_information',
    ];
}
  1. If users are merged, all pieces of information need to be assigned to the target user of the merging. Again, we only have to specify the name of relevant database table if AbstractUserMergeListener is extended:
files/lib/system/event/listener/PersonUserMergeListener.class.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

namespace wcf\system\event\listener;

/**
 * Updates person information during user merging.
 *
 * @author  Matthias Schmidt
 * @copyright   2001-2021 WoltLab GmbH
 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 * @package WoltLabSuite\Core\System\Event\Listener
 */
class PersonUserMergeListener extends AbstractUserMergeListener
{
    /**
     * @inheritDoc
     */
    protected $databaseTables = [
        'wcf{WCF_N}_person_information',
    ];
}
  1. If the option to prune stored ip addresses after a certain period of time is enabled, we also have to prune them in the person information database table. Here we also only have to specify the name of the relevant database table and provide the mapping from the ipAddress column to the time column:
files/lib/system/event/listener/PersonPruneIpAddressesCronjobListener.class.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

namespace wcf\system\event\listener;

use wcf\system\cronjob\PruneIpAddressesCronjob;

/**
 * Prunes old ip addresses.
 *
 * @author  Matthias Schmidt
 * @copyright   2001-2021 WoltLab GmbH
 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 * @package WoltLabSuite\Core\System\Event\Listener
 */
class PersonPruneIpAddressesCronjobListener extends AbstractEventListener
{
    protected function onExecute(PruneIpAddressesCronjob $cronjob): void
    {
        $cronjob->columns['wcf' . WCF_N . '_person_information']['ipAddress'] = 'time';
    }
}
  1. The ip addresses in the person information database table also have to be considered for the user data export which can also be done with minimal effort by providing the name of the relevant database table:
files/lib/system/event/listener/PersonUserExportGdprListener.class.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

namespace wcf\system\event\listener;

use wcf\acp\action\UserExportGdprAction;

/**
 * Adds the ip addresses stored with the person information during user data export.
 *
 * @author  Matthias Schmidt
 * @copyright   2001-2021 WoltLab GmbH
 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 * @package WoltLabSuite\Core\System\Event\Listener
 */
class PersonUserExportGdprListener extends AbstractEventListener
{
    protected function onExport(UserExportGdprAction $action): void
    {
        $action->ipAddresses['com.woltlab.wcf.people'] = ['wcf' . WCF_N . '_person_information'];
    }
}

Lastly, we present the updated eventListener.xml file with new entries for all of these event listeners:

eventListener.xml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?xml version="1.0" encoding="UTF-8"?>
<data xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/5.4/eventListener.xsd">
    <import>
        <eventlistener name="rename@wcf\data\user\UserAction">
            <eventclassname>wcf\data\user\UserAction</eventclassname>
            <eventname>rename</eventname>
            <listenerclassname>wcf\system\event\listener\PersonUserActionRenameListener</listenerclassname>
            <environment>all</environment>
        </eventlistener>
        <eventlistener name="save@wcf\acp\form\UserMergeForm">
            <eventclassname>wcf\acp\form\UserMergeForm</eventclassname>
            <eventname>save</eventname>
            <listenerclassname>wcf\system\event\listener\PersonUserMergeListener</listenerclassname>
            <environment>admin</environment>
        </eventlistener>
        <eventlistener name="execute@wcf\system\cronjob\PruneIpAddressesCronjob">
            <eventclassname>wcf\system\cronjob\PruneIpAddressesCronjob</eventclassname>
            <eventname>execute</eventname>
            <listenerclassname>wcf\system\event\listener\PersonPruneIpAddressesCronjobListener</listenerclassname>
            <environment>all</environment>
        </eventlistener>
        <eventlistener name="export@wcf\acp\action\UserExportGdprAction">
            <eventclassname>wcf\acp\action\UserExportGdprAction</eventclassname>
            <eventname>export</eventname>
            <listenerclassname>wcf\system\event\listener\PersonUserExportGdprListener</listenerclassname>
            <environment>admin</environment>
        </eventlistener>
    </import>
</data>

Last update: 2021-05-03