commit 63b5556b21594c5a89726c9941520a9342af1348 Author: Jürgen Mummert Date: Tue Feb 17 18:53:23 2026 +0100 Initial release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef2f9c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor/ +/var/ +/.idea/ +/.vscode/ +/.DS_Store +.DS_Store +/composer.lock diff --git a/ARCHITECTURE_PROMPT.md b/ARCHITECTURE_PROMPT.md new file mode 100644 index 0000000..7dbe625 --- /dev/null +++ b/ARCHITECTURE_PROMPT.md @@ -0,0 +1,216 @@ +Create a Contao 5.7 bundle (PHP 8.4) + +Vendor: MummertMedia +Bundle: EventManagerBundle +Composer name: mummert-media/eventmanager-bundle +Type: contao-bundle + +This bundle is internal only. + +IMPORTANT: +Only implement backend functionality. +No frontend logic. +No controllers. +No services. +No access logic. +No event listeners. +No future planning. +Only backend entities and relations. + +==================================================== +GOAL +==================================================== + +Extend Contao backend with: + +1) New entity: tl_organization named Organisationen, just german translation +2) New entity: tl_location named Veranstaltungsorte, just german translation +3) Join table: tl_member_organization +4) Join table: tl_calendar_events_organization +5) Extend tl_calendar_events with: + - location_id (exactly one location) with dropdown in Backend (title ASC) + - organization assignment (multiple via join table) +6) Extend tl_member so that: + - A member can belong to multiple organizations +7) Extend tl_organization so that: + - Multiple members can be assigned to it + +Everything manageable via Contao backend only. + +==================================================== +DATABASE TABLES +==================================================== + +Use Contao DCA SQL definitions (DBAL 4 compatible). +Do NOT use serialized fields. +Do NOT use string length values like "255". +Use integer values. + +------------------------------------ +tl_organization + add german translation +------------------------------------ + +id int unsigned auto_increment +pid int unsigned default 0 +tstamp int unsigned default 0 +dateAdded int unsigned default 0 +title varchar(255) +alias varchar(128) +logo binary(16) nullable +street varchar(255) +street2 varchar(255) +postal varchar(255) +city varchar(255) +state varchar(255) +country varchar(2) +phone varchar(64) +email varchar(255) +website varchar(255) +lat decimal(10,8) +lng decimal(11,8) +description text nullable +published char(1) +isExternal char(1) +type varchar(32) + +------------------------------------ +tl_location + add german translation +------------------------------------ + +id int unsigned auto_increment +tstamp int unsigned default 0 +dateAdded int unsigned default 0 +title varchar(255) +alias varchar(128) +description text nullable +street varchar(255) +street2 varchar(255) +postal varchar(255) +city varchar(255) +state varchar(255) +country varchar(2) default 'de' +lat decimal(10,8) +lng decimal(11,8) +image binary(16) nullable +published char(1) + +------------------------------------ +tl_member_organization +------------------------------------ + +id int unsigned auto_increment +tstamp int unsigned default 0 +member_id int unsigned +organization_id int unsigned + +------------------------------------ +tl_calendar_events_organization +------------------------------------ + +id int unsigned auto_increment +tstamp int unsigned default 0 +event_id int unsigned +organization_id int unsigned + +==================================================== +DCA REQUIREMENTS +==================================================== + +1) tl_organization: + - Backend list view + - Editable fields + - Published toggle + - Alias field + - Type as select field with static options: + accommodation, shopping, culture + - Add multi-relation field for members + (store relation in tl_member_organization) + +2) tl_location: + - Backend list view + - Editable fields + - Published toggle + - Alias field + +3) tl_calendar_events: + - Add field location_id (select, mandatory) + foreignKey: tl_location.title + - Add multi-organization relation + (store relation in tl_calendar_events_organization) + +4) tl_member: + - Add multi-organization relation + (store relation in tl_member_organization) + +==================================================== +RELATION HANDLING +==================================================== + +Use proper many-to-many handling via join tables. +Use relation definitions in DCA. +Do NOT use serialized arrays. +Do NOT store IDs as comma-separated strings. + +==================================================== +BACKEND INTEGRATION +==================================================== + +Register backend modules for: + +- Organisationen +- Veranstaltungsorte + +Under backend group content + + +Use standard Contao backend module registration. + +==================================================== +PROJECT STRUCTURE +==================================================== + +Generate: + +src/ + MummertMediaEventManagerBundle.php + DependencyInjection/ + MummertMediaEventManagerExtension.php + Contao/ + Manager/ + Plugin.php + +contao/ + dca/ + tl_organization.php + tl_location.php + tl_member.php + tl_calendar_events.php + config/ + config.php + +config/ + services.yaml + +composer.json + +==================================================== +QUALITY RULES +==================================================== + +- declare(strict_types=1); +- PHP 8.4 +- DBAL 4 compatible SQL +- no deprecated Contao 4 syntax +- no frontend logic +- no controllers +- no services +- no placeholders +- complete working DCA +- ready to install via path repository + +==================================================== +OUTPUT +==================================================== + +Generate full file contents for all required files. +Code must be production-ready. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..85ce187 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) Mummert Media + +All rights reserved. + +This package is proprietary software. +No part of this package may be copied, modified, distributed, sublicensed, +or used in any form without prior written permission of the copyright holder. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c17405 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Eventmanager Bundle + +Contao 5 Bundle für Organisations-, Veranstaltungsort- und Event-Verwaltung inkl. Frontend-Module. + +## Voraussetzungen + +- PHP `^8.4` +- Contao `^5.7` + +## Installation (lokal via Composer) + +```bash +composer require mummert-media/eventmanager-bundle +``` + +## Installation im Contao Manager (VCS) + +1. **Contao Manager** → **Composer** → **Repositories** → **Repository hinzufügen** +2. Typ: `vcs` +3. URL: `https://github.com//eventmanager-bundle` +4. Paket hinzufügen: `mummert-media/eventmanager-bundle` +5. Bei Bedarf Version `dev-main` oder ein Tag (z. B. `1.0.0`) wählen +6. Änderungen anwenden + +## Release / Versionierung + +SemVer-Tags verwenden: + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +## GitHub Setup + +```bash +git init +git add . +git commit -m "Initial release" +git branch -M main +git remote add origin git@github.com:/eventmanager-bundle.git +git push -u origin main +``` + +## Optional: Packagist + +1. Repository auf Packagist einreichen +2. GitHub Service Hook / Auto-Update aktivieren +3. Neue Versionen über Git-Tags veröffentlichen diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d04cf61 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "mummert-media/eventmanager-bundle", + "description": "Internal Contao backend bundle for organizations, locations and event/member assignments.", + "type": "contao-bundle", + "license": "proprietary", + "keywords": [ + "contao", + "contao-bundle", + "eventmanager" + ], + "require": { + "php": "^8.4", + "contao/core-bundle": "^5.7", + "contao/manager-plugin": "^2.0" + }, + "autoload": { + "psr-4": { + "MummertMedia\\EventManagerBundle\\": "src/" + } + }, + "extra": { + "contao-manager-plugin": "MummertMedia\\EventManagerBundle\\Contao\\Manager\\Plugin" + }, + "prefer-stable": true +} diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..5e370e1 --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,16 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + MummertMedia\EventManagerBundle\: + resource: ../src/ + exclude: + - ../src/DependencyInjection/ + - ../src/Contao/Manager/ + - ../src/MummertMediaEventManagerBundle.php + + MummertMedia\EventManagerBundle\EventListener\DataContainer\SetDateAddedCallback: + tags: + - { name: contao.callback, table: tl_organization, target: config.onbeforesubmit, method: onBeforeSubmit } + - { name: contao.callback, table: tl_location, target: config.onbeforesubmit, method: onBeforeSubmit } diff --git a/contao/config/config.php b/contao/config/config.php new file mode 100644 index 0000000..de50643 --- /dev/null +++ b/contao/config/config.php @@ -0,0 +1,14 @@ + ['tl_organization'], +]; + +$GLOBALS['BE_MOD']['content']['eventmanager_veranstaltungsorte'] = [ + 'tables' => ['tl_location'], +]; diff --git a/contao/dca/tl_calendar_events.php b/contao/dca/tl_calendar_events.php new file mode 100644 index 0000000..a64eb75 --- /dev/null +++ b/contao/dca/tl_calendar_events.php @@ -0,0 +1,180 @@ +addLegend('organization_legend', 'details_legend', PaletteManipulator::POSITION_AFTER) + ->addField(['location_id', 'type', 'organizations'], 'organization_legend', PaletteManipulator::POSITION_APPEND) + ->applyToPalette((string) $paletteName, 'tl_calendar_events'); + + PaletteManipulator::create() + ->addField('photographer', 'image_legend', PaletteManipulator::POSITION_APPEND) + ->applyToPalette((string) $paletteName, 'tl_calendar_events'); + + PaletteManipulator::create() + ->addField(['source', 'url', 'target'], 'details_legend', PaletteManipulator::POSITION_APPEND) + ->addField(['termsAccepted', 'isSoldOut', 'isCanceled'], 'publish_legend', PaletteManipulator::POSITION_APPEND) + ->applyToPalette((string) $paletteName, 'tl_calendar_events'); + } +}; + +$GLOBALS['TL_DCA']['tl_calendar_events']['config']['onbeforesubmit_callback'][] = static function (array $values): array { + unset($values['organizations']); + + return $values; +}; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['location_id'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['location_id'], + 'exclude' => true, + 'filter' => true, + 'sorting' => true, + 'inputType' => 'select', + 'foreignKey' => 'tl_location.title', + 'options_callback' => static function (): array { + $result = Database::getInstance() + ->query('SELECT id, title FROM tl_location ORDER BY title ASC'); + + $options = []; + + foreach ($result->fetchAllAssoc() as $row) { + $options[(int) $row['id']] = (string) $row['title']; + } + + return $options; + }, + 'eval' => ['mandatory' => true, 'includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'], + 'relation' => [ + 'type' => 'hasOne', + 'load' => 'eager', + 'table' => 'tl_location', + 'field' => 'id', + ], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], +]; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['type'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['type'], + 'exclude' => true, + 'inputType' => 'select', + 'options' => ['accommodation', 'shopping', 'culture'], + 'reference' => &$GLOBALS['TL_LANG']['tl_calendar_events']['type_options'], + 'eval' => ['multiple' => true, 'chosen' => true, 'includeBlankOption' => false, 'tl_class' => 'w50'], + 'sql' => ['type' => 'blob', 'notnull' => false], +]; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['organizations'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['organizations'], + 'exclude' => true, + 'inputType' => 'select', + 'foreignKey' => 'tl_organization.title', + 'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'], + 'relation' => [ + 'type' => 'hasMany', + 'load' => 'lazy', + 'table' => 'tl_organization', + 'field' => 'id', + ], + 'load_callback' => [ + static function ($value, DataContainer $dc): array { + if (!$dc->id) { + return []; + } + + $result = Database::getInstance() + ->prepare('SELECT organization_id FROM tl_calendar_events_organization WHERE event_id=? ORDER BY organization_id') + ->execute($dc->id); + + return array_map(static fn (array $row): int => (int) $row['organization_id'], $result->fetchAllAssoc()); + }, + ], + 'save_callback' => [ + static function ($value, DataContainer $dc): array { + if (!$dc->id) { + return []; + } + + $eventId = (int) $dc->id; + $organizationIds = array_values(array_unique(array_map('intval', StringUtil::deserialize($value, true)))); + $db = Database::getInstance(); + $time = time(); + + $db->prepare('DELETE FROM tl_calendar_events_organization WHERE event_id=?') + ->execute($eventId); + + foreach ($organizationIds as $organizationId) { + $db->prepare('INSERT INTO tl_calendar_events_organization (tstamp, event_id, organization_id) VALUES (?, ?, ?)') + ->execute($time, $eventId, $organizationId); + } + + return $organizationIds; + }, + ], +]; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['photographer'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['photographer'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], +]; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['isSoldOut'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['isSoldOut'], + 'exclude' => true, + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''], +]; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['isCanceled'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['isCanceled'], + 'exclude' => true, + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''], +]; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['termsAccepted'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['termsAccepted'], + 'exclude' => true, + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'clr'], + 'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''], +]; + +if (isset($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['source']) && is_array($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['source'])) { + $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['source']['eval'] ??= []; + $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['source']['eval']['tl_class'] = 'w50'; +} + +if (isset($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['target']) && is_array($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['target'])) { + $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['target']['eval'] ??= []; + $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['target']['eval']['tl_class'] = 'w50'; +} + +if (isset($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['url']) && is_array($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['url'])) { + $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['url']['eval'] ??= []; + $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['url']['eval']['tl_class'] = 'clr'; + $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['url']['eval']['mandatory'] = false; + unset($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['url']['eval']['rgxp']); +} diff --git a/contao/dca/tl_calendar_events_organization.php b/contao/dca/tl_calendar_events_organization.php new file mode 100644 index 0000000..dff632d --- /dev/null +++ b/contao/dca/tl_calendar_events_organization.php @@ -0,0 +1,32 @@ + [ + 'dataContainer' => DC_Table::class, + 'sql' => [ + 'keys' => [ + 'id' => 'primary', + 'event_id' => 'index', + 'organization_id' => 'index', + ], + ], + ], + 'fields' => [ + 'id' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true, 'notnull' => true], + ], + 'tstamp' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0, 'notnull' => true], + ], + 'event_id' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0, 'notnull' => true], + ], + 'organization_id' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0, 'notnull' => true], + ], + ], +]; diff --git a/contao/dca/tl_location.php b/contao/dca/tl_location.php new file mode 100644 index 0000000..e817bb7 --- /dev/null +++ b/contao/dca/tl_location.php @@ -0,0 +1,196 @@ + [ + 'dataContainer' => DC_Table::class, + 'enableVersioning' => true, + 'sql' => [ + 'keys' => [ + 'id' => 'primary', + 'alias' => 'index', + 'published' => 'index', + 'title' => 'index', + ], + ], + ], + 'list' => [ + 'sorting' => [ + 'mode' => 1, + 'fields' => ['title'], + 'flag' => 1, + 'panelLayout' => 'search,limit', + ], + 'label' => [ + 'fields' => ['title', 'city'], + 'format' => '%s (%s)', + ], + 'global_operations' => [ + 'all' => [ + 'href' => 'act=select', + 'class' => 'header_edit_all', + 'attributes' => 'onclick="Backend.getScrollOffset()" accesskey="e"', + ], + ], + 'operations' => [ + 'edit' => [ + 'href' => 'act=edit', + 'icon' => 'edit.svg', + ], + 'copy' => [ + 'href' => 'act=copy', + 'icon' => 'copy.svg', + ], + 'delete' => [ + 'href' => 'act=delete', + 'icon' => 'delete.svg', + 'attributes' => 'onclick="if(!confirm(\'' . (string) ($GLOBALS['TL_LANG']['MSC']['deleteConfirm'] ?? '') . '\'))return false;Backend.getScrollOffset()"', + ], + 'toggle' => [ + 'href' => 'act=toggle&field=published', + 'icon' => 'visible.svg', + ], + 'show' => [ + 'href' => 'act=show', + 'icon' => 'show.svg', + ], + ], + ], + 'palettes' => [ + '__selector__' => [], + 'default' => '{title_legend},title,alias,image;{address_legend},street,street2,postal,city,state,country;{geo_legend},lat,lng;{description_legend},description;{publish_legend},published', + ], + 'fields' => [ + 'id' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true], + ], + 'tstamp' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'dateAdded' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'title' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_location']['title'], + 'exclude' => true, + 'search' => true, + 'sorting' => true, + 'inputType' => 'text', + 'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'alias' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_location']['alias'], + 'exclude' => true, + 'search' => true, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'alias', 'unique' => true, 'maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'description' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_location']['description'], + 'exclude' => true, + 'inputType' => 'textarea', + 'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'], + 'sql' => ['type' => 'text', 'notnull' => false], + ], + 'street' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_location']['street'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'street2' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_location']['street2'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'postal' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_location']['postal'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'city' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_location']['city'], + 'exclude' => true, + 'search' => true, + 'sorting' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'state' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_location']['state'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'country' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_location']['country'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 2, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 2, 'default' => 'de'], + ], + 'lat' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_location']['lat'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 10, 'tl_class' => 'w50'], + 'sql' => ['type' => 'decimal', 'precision' => 10, 'scale' => 8, 'default' => '0.00000000'], + ], + 'lng' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_location']['lng'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 11, 'tl_class' => 'w50'], + 'sql' => ['type' => 'decimal', 'precision' => 11, 'scale' => 8, 'default' => '0.00000000'], + ], + 'image' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_location']['image'], + 'exclude' => true, + 'inputType' => 'fileTree', + 'eval' => ['filesOnly' => true, 'fieldType' => 'radio', 'tl_class' => 'clr'], + 'sql' => ['type' => 'binary', 'length' => 16, 'notnull' => false], + ], + 'published' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_location']['published'], + 'exclude' => true, + 'filter' => true, + 'toggle' => true, + 'inputType' => 'checkbox', + 'eval' => ['doNotCopy' => true], + 'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''], + ], + ], +]; + +$GLOBALS['TL_DCA']['tl_location']['fields']['alias']['save_callback'][] = static function (string $value, DataContainer $dc): string { + if ($value !== '') { + return $value; + } + + $value = StringUtil::generateAlias($dc->activeRecord?->title ?? ''); + + $exists = Database::getInstance() + ->prepare('SELECT id FROM tl_location WHERE alias=? AND id!=?') + ->execute($value, (int) ($dc->id ?? 0)); + + if ($exists->numRows > 0) { + throw new \RuntimeException(sprintf((string) ($GLOBALS['TL_LANG']['tl_location']['aliasExists'] ?? '%s'), $value)); + } + + return $value; +}; diff --git a/contao/dca/tl_member.php b/contao/dca/tl_member.php new file mode 100644 index 0000000..78bdcb5 --- /dev/null +++ b/contao/dca/tl_member.php @@ -0,0 +1,102 @@ +addLegend('organization_legend', 'contact_legend', PaletteManipulator::POSITION_AFTER) + ->addField('organizations', 'organization_legend', PaletteManipulator::POSITION_APPEND) + ->applyToPalette((string) $paletteName, 'tl_member'); + } +} + +$GLOBALS['TL_DCA']['tl_member']['config']['onbeforesubmit_callback'][] = static function (array $values): array { + unset($values['organizations']); + + return $values; +}; + +$GLOBALS['TL_DCA']['tl_member']['config']['onsubmit_callback'][] = static function (DataContainer $dc): void { + if (!$dc->id) { + return; + } + + $postedValues = Input::post('organizations'); + + if (null === $postedValues) { + return; + } + + $organizationIds = array_values(array_unique(array_map('intval', is_array($postedValues) ? $postedValues : [$postedValues]))); + $memberId = (int) $dc->id; + $db = Database::getInstance(); + $time = time(); + + $db->prepare('DELETE FROM tl_member_organization WHERE member_id=?') + ->execute($memberId); + + foreach ($organizationIds as $organizationId) { + $db->prepare('INSERT INTO tl_member_organization (tstamp, member_id, organization_id) VALUES (?, ?, ?)') + ->execute($time, $memberId, $organizationId); + } +}; + +$GLOBALS['TL_DCA']['tl_member']['fields']['organizations'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_member']['organizations'], + 'exclude' => true, + 'inputType' => 'select', + 'foreignKey' => 'tl_organization.title', + 'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'], + 'relation' => [ + 'type' => 'hasMany', + 'load' => 'lazy', + 'table' => 'tl_organization', + 'field' => 'id', + ], + 'load_callback' => [ + static function ($value, DataContainer $dc): array { + if (!$dc->id) { + return []; + } + + $result = Database::getInstance() + ->prepare('SELECT organization_id FROM tl_member_organization WHERE member_id=? ORDER BY organization_id') + ->execute($dc->id); + + return array_map(static fn (array $row): int => (int) $row['organization_id'], $result->fetchAllAssoc()); + }, + ], + 'save_callback' => [ + static function ($value, DataContainer $dc): array { + if (!$dc->id) { + return []; + } + + $memberId = (int) $dc->id; + $organizationIds = array_values(array_unique(array_map('intval', StringUtil::deserialize($value, true)))); + $db = Database::getInstance(); + $time = time(); + + $db->prepare('DELETE FROM tl_member_organization WHERE member_id=?') + ->execute($memberId); + + foreach ($organizationIds as $organizationId) { + $db->prepare('INSERT INTO tl_member_organization (tstamp, member_id, organization_id) VALUES (?, ?, ?)') + ->execute($time, $memberId, $organizationId); + } + + return $organizationIds; + }, + ], +]; diff --git a/contao/dca/tl_member_organization.php b/contao/dca/tl_member_organization.php new file mode 100644 index 0000000..37675d0 --- /dev/null +++ b/contao/dca/tl_member_organization.php @@ -0,0 +1,32 @@ + [ + 'dataContainer' => DC_Table::class, + 'sql' => [ + 'keys' => [ + 'id' => 'primary', + 'member_id' => 'index', + 'organization_id' => 'index', + ], + ], + ], + 'fields' => [ + 'id' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true, 'notnull' => true], + ], + 'tstamp' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0, 'notnull' => true], + ], + 'member_id' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0, 'notnull' => true], + ], + 'organization_id' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0, 'notnull' => true], + ], + ], +]; diff --git a/contao/dca/tl_module.php b/contao/dca/tl_module.php new file mode 100644 index 0000000..3498f24 --- /dev/null +++ b/contao/dca/tl_module.php @@ -0,0 +1,64 @@ + &$GLOBALS['TL_LANG']['tl_module']['editPage'], + 'exclude' => true, + 'inputType' => 'pageTree', + 'eval' => ['fieldType' => 'radio', 'mandatory' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['listPage'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['listPage'], + 'exclude' => true, + 'inputType' => 'pageTree', + 'eval' => ['fieldType' => 'radio', 'mandatory' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['logoFolder'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['logoFolder'], + 'exclude' => true, + 'inputType' => 'fileTree', + 'eval' => ['fieldType' => 'radio', 'files' => false, 'mandatory' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'binary', 'length' => 16, 'notnull' => false], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['eventFolder'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['eventFolder'], + 'exclude' => true, + 'inputType' => 'fileTree', + 'eval' => ['fieldType' => 'radio', 'files' => false, 'mandatory' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'binary', 'length' => 16, 'notnull' => false], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['termsPage'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['termsPage'], + 'exclude' => true, + 'inputType' => 'pageTree', + 'eval' => ['fieldType' => 'radio', 'mandatory' => false, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['frontendAuthorId'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['frontendAuthorId'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['mandatory' => true, 'rgxp' => 'digit', 'maxlength' => 10, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['frontendArchiveId'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['frontendArchiveId'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['mandatory' => true, 'rgxp' => 'digit', 'maxlength' => 10, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], +]; diff --git a/contao/dca/tl_organization.php b/contao/dca/tl_organization.php new file mode 100644 index 0000000..c8c2bf1 --- /dev/null +++ b/contao/dca/tl_organization.php @@ -0,0 +1,304 @@ + [ + 'dataContainer' => DC_Table::class, + 'enableVersioning' => true, + 'onbeforesubmit_callback' => [ + static function (array $values): array { + unset($values['members']); + + return $values; + }, + ], + 'sql' => [ + 'keys' => [ + 'id' => 'primary', + 'alias' => 'index', + 'published' => 'index', + ], + ], + ], + 'list' => [ + 'sorting' => [ + 'mode' => 1, + 'fields' => ['title'], + 'flag' => 1, + 'panelLayout' => 'search,limit', + ], + 'label' => [ + 'fields' => ['title', 'city'], + 'format' => '%s (%s)', + ], + 'global_operations' => [ + 'all' => [ + 'href' => 'act=select', + 'class' => 'header_edit_all', + 'attributes' => 'onclick="Backend.getScrollOffset()" accesskey="e"', + ], + ], + 'operations' => [ + 'edit' => [ + 'href' => 'act=edit', + 'icon' => 'edit.svg', + ], + 'copy' => [ + 'href' => 'act=copy', + 'icon' => 'copy.svg', + ], + 'delete' => [ + 'href' => 'act=delete', + 'icon' => 'delete.svg', + 'attributes' => 'onclick="if(!confirm(\'' . (string) ($GLOBALS['TL_LANG']['MSC']['deleteConfirm'] ?? '') . '\'))return false;Backend.getScrollOffset()"', + ], + 'toggle' => [ + 'href' => 'act=toggle&field=published', + 'icon' => 'visible.svg', + ], + 'show' => [ + 'href' => 'act=show', + 'icon' => 'show.svg', + ], + ], + ], + 'palettes' => [ + '__selector__' => [], + 'default' => '{title_legend},title,alias,type,isExternal,logo;{address_legend},street,street2,postal,city,state,country;{contact_legend},phone,email,website;{geo_legend},lat,lng;{description_legend},description;{relation_legend},members;{publish_legend},published', + ], + 'fields' => [ + 'id' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true], + ], + 'pid' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'tstamp' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'dateAdded' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'title' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['title'], + 'exclude' => true, + 'search' => true, + 'sorting' => true, + 'inputType' => 'text', + 'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'alias' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['alias'], + 'exclude' => true, + 'search' => true, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'alias', 'unique' => true, 'maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'logo' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['logo'], + 'exclude' => true, + 'inputType' => 'fileTree', + 'eval' => ['filesOnly' => true, 'fieldType' => 'radio', 'tl_class' => 'clr'], + 'sql' => ['type' => 'binary', 'length' => 16, 'notnull' => false], + ], + 'street' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['street'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'street2' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['street2'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'postal' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['postal'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'city' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['city'], + 'exclude' => true, + 'search' => true, + 'sorting' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'state' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['state'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'country' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['country'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 2, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 2, 'default' => ''], + ], + 'phone' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['phone'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 64, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 64, 'default' => ''], + ], + 'email' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['email'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'email', 'maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'website' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['website'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'url', 'maxlength' => 255, 'tl_class' => 'clr'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'lat' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['lat'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 10, 'tl_class' => 'w50'], + 'sql' => ['type' => 'decimal', 'precision' => 10, 'scale' => 8, 'default' => '0.00000000'], + ], + 'lng' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['lng'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 11, 'tl_class' => 'w50'], + 'sql' => ['type' => 'decimal', 'precision' => 11, 'scale' => 8, 'default' => '0.00000000'], + ], + 'description' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['description'], + 'exclude' => true, + 'inputType' => 'textarea', + 'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'], + 'sql' => ['type' => 'text', 'notnull' => false], + ], + 'published' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['published'], + 'exclude' => true, + 'filter' => true, + 'toggle' => true, + 'inputType' => 'checkbox', + 'eval' => ['doNotCopy' => true], + 'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''], + ], + 'isExternal' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['isExternal'], + 'exclude' => true, + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''], + ], + 'type' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['type'], + 'exclude' => true, + 'inputType' => 'select', + 'options' => ['accommodation', 'shopping', 'culture'], + 'reference' => &$GLOBALS['TL_LANG']['tl_organization']['type_options'], + 'eval' => ['multiple' => true, 'chosen' => true, 'includeBlankOption' => false, 'tl_class' => 'w50'], + 'sql' => ['type' => 'blob', 'notnull' => false], + ], + 'members' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_organization']['members'], + 'exclude' => true, + 'inputType' => 'select', + 'options_callback' => static function (): array { + $result = Database::getInstance() + ->query('SELECT id, firstname, lastname FROM tl_member ORDER BY lastname ASC, firstname ASC'); + + $options = []; + + foreach ($result->fetchAllAssoc() as $row) { + $name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? '')); + $options[(int) $row['id']] = $name !== '' + ? $name + : sprintf((string) ($GLOBALS['TL_LANG']['tl_organization']['memberFallback'] ?? '%s'), $row['id']); + } + + return $options; + }, + 'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'], + 'relation' => [ + 'type' => 'hasMany', + 'load' => 'lazy', + 'table' => 'tl_member', + 'field' => 'id', + ], + 'load_callback' => [ + static function ($value, DataContainer $dc): array { + if (!$dc->id) { + return []; + } + + $result = Database::getInstance() + ->prepare('SELECT member_id FROM tl_member_organization WHERE organization_id=? ORDER BY member_id') + ->execute($dc->id); + + return array_map(static fn (array $row): int => (int) $row['member_id'], $result->fetchAllAssoc()); + }, + ], + 'save_callback' => [ + static function ($value, DataContainer $dc): array { + if (!$dc->id) { + return []; + } + + $organizationId = (int) $dc->id; + $memberIds = array_values(array_unique(array_map('intval', StringUtil::deserialize($value, true)))); + $db = Database::getInstance(); + $time = time(); + + $db->prepare('DELETE FROM tl_member_organization WHERE organization_id=?') + ->execute($organizationId); + + foreach ($memberIds as $memberId) { + $db->prepare('INSERT INTO tl_member_organization (tstamp, member_id, organization_id) VALUES (?, ?, ?)') + ->execute($time, $memberId, $organizationId); + } + + return $memberIds; + }, + ], + ], + ], +]; + +$GLOBALS['TL_DCA']['tl_organization']['fields']['alias']['save_callback'][] = static function (string $value, DataContainer $dc): string { + if ($value !== '') { + return $value; + } + + $value = StringUtil::generateAlias($dc->activeRecord?->title ?? ''); + + $exists = Database::getInstance() + ->prepare('SELECT id FROM tl_organization WHERE alias=? AND id!=?') + ->execute($value, (int) ($dc->id ?? 0)); + + if ($exists->numRows > 0) { + throw new \RuntimeException(sprintf((string) ($GLOBALS['TL_LANG']['tl_organization']['aliasExists'] ?? '%s'), $value)); + } + + return $value; +}; diff --git a/contao/languages/de/modules.php b/contao/languages/de/modules.php new file mode 100644 index 0000000..8451e0b --- /dev/null +++ b/contao/languages/de/modules.php @@ -0,0 +1,12 @@ +{{ error }}

+

Zurück

+ {% elseif form is defined and form %} + + + + + + + + + + + + {{ form_start(form, { action: app.request.uri, attr: { 'aria-live': 'polite' } }) }} + + + + {{ form_row(form.title) }} + {{ form_row(form.startDate) }} + {{ form_row(form.endDate) }} + {{ form_row(form.addTime) }} + + {% if form.organization_ids is defined %} + {{ form_row(form.organization_ids) }} + {% endif %} + {{ form_row(form.location_id) }} + {{ form_row(form.type) }} + {{ form_row(form.teaser) }} + {{ form_row(form.description) }} + {{ form_row(form.url) }} + {{ form_row(form.addImage) }} + + + + {{ form_row(form.termsAccepted) }} + {{ form_row(form.isSoldOut) }} + {{ form_row(form.isCanceled) }} + {{ form_row(form.published) }} +
+ + + Zurück +
+ {{ form_end(form) }} + + + {% else %} +

Kein Formular verfügbar.

+ {% endif %} +{% endblock %} diff --git a/contao/templates/frontend/member_events.html.twig b/contao/templates/frontend/member_events.html.twig new file mode 100644 index 0000000..555e086 --- /dev/null +++ b/contao/templates/frontend/member_events.html.twig @@ -0,0 +1,101 @@ +{% extends "@Contao/frontend_module/_base.html.twig" %} + +{% block content %} +

Kommende Veranstaltungen

+ {% if upcomingEvents is empty %} +

Keine kommenden Veranstaltungen gefunden.

+ {% else %} +
    + {% for item in upcomingEvents %} +
  • + {{ item.title }} + + {% if item.startDate %} + + {% endif %} + + + {% if isEditor %} +
    + + + + +
    + + {% if item.editUrl %} + Bearbeiten + {% endif %} + +
    + + + + +
    + +
    + + + + +
    + {% endif %} +
  • + {% endfor %} +
+ {% endif %} + + {% if canCreateEvent is defined and canCreateEvent %} +
+ + + +
+ {% endif %} + +

Vergangene Veranstaltungen

+ {% if pastEvents is empty %} +

Keine vergangenen Veranstaltungen gefunden.

+ {% else %} +
    + {% for item in pastEvents %} +
  • + {{ item.title }} + + {% if item.startDate %} + + {% endif %} + + + {% if isEditor %} +
    + + + + +
    + + {% if item.editUrl %} + Bearbeiten + {% endif %} + +
    + + + + +
    + +
    + + + + +
    + {% endif %} +
  • + {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/contao/templates/frontend/member_organizations.html.twig b/contao/templates/frontend/member_organizations.html.twig new file mode 100644 index 0000000..15c66c7 --- /dev/null +++ b/contao/templates/frontend/member_organizations.html.twig @@ -0,0 +1,19 @@ +{% extends "@Contao/frontend_module/_base.html.twig" %} + +{% block content %} +

Organisationen

+ {% if organizations is empty %} +

Keine Organisationen gefunden.

+ {% else %} +
    + {% for item in organizations %} +
  • + {{ item.title }} + {% if item.editUrl %} + Bearbeiten + {% endif %} +
  • + {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/contao/templates/frontend/organization_edit.html.twig b/contao/templates/frontend/organization_edit.html.twig new file mode 100644 index 0000000..e2a4dcd --- /dev/null +++ b/contao/templates/frontend/organization_edit.html.twig @@ -0,0 +1,169 @@ +{% extends "@Contao/frontend_module/_base.html.twig" %} + +{% block content %} + {% if error is defined and error %} +

{{ error }}

+

Zurück

+ {% elseif form is defined and form %} + + + + + + + + {{ form_start(form, { attr: { 'aria-live': 'polite' } }) }} + + + {{ form_widget(form) }} +
+ + + Zurück +
+ {{ form_end(form) }} + + + {% else %} +

Kein Formular verfügbar.

+ {% endif %} +{% endblock %} diff --git a/src/Contao/Manager/Plugin.php b/src/Contao/Manager/Plugin.php new file mode 100644 index 0000000..a638b85 --- /dev/null +++ b/src/Contao/Manager/Plugin.php @@ -0,0 +1,23 @@ +setLoadAfter([ContaoCoreBundle::class, ContaoCalendarBundle::class]), + ]; + } +} diff --git a/src/Controller/Frontend/EventEditController.php b/src/Controller/Frontend/EventEditController.php new file mode 100644 index 0000000..2bc2bf4 --- /dev/null +++ b/src/Controller/Frontend/EventEditController.php @@ -0,0 +1,384 @@ +getUser(); + $backUrl = $this->resolveBackUrl($request, $model); + $eventParam = $request->query->get('event'); + $isCreateMode = '1' === (string) $request->query->get('create', '0') && (null === $eventParam || (int) $eventParam <= 0); + + if (!$user instanceof FrontendUser) { + $template->set('error', 'Bitte zuerst als Mitglied einloggen.'); + $template->set('backUrl', $backUrl); + + return $template->getResponse(); + } + + $eventId = $isCreateMode ? 0 : (int) $eventParam; + $event = null; + + if (!$isCreateMode) { + if ($eventId <= 0 || !$this->eventRepository->memberHasEvent((int) $user->id, $eventId)) { + $template->set('error', 'Keine Berechtigung für diese Veranstaltung oder ungültige ID.'); + $template->set('backUrl', $backUrl); + + return $template->getResponse(); + } + + $event = $this->eventRepository->findById($eventId); + + if (null === $event) { + $template->set('error', 'Veranstaltung nicht gefunden.'); + $template->set('backUrl', $backUrl); + + return $template->getResponse(); + } + } + + if ($isCreateMode) { + $event = [ + 'title' => '', + 'startDate' => null, + 'endDate' => null, + 'addTime' => '', + 'startTime' => null, + 'endTime' => null, + 'location_id' => 0, + 'type' => null, + 'teaser' => '', + 'description' => '', + 'url' => '', + 'photographer' => '', + 'addImage' => '', + 'termsAccepted' => '', + 'isSoldOut' => '', + 'isCanceled' => '', + 'published' => '', + 'singleSRC' => null, + ]; + } + + $memberOrganizationIds = $this->eventRepository->getOrganizationIdsForMember((int) $user->id); + $organizationChoices = $this->eventRepository->getOrganizationChoicesForMember((int) $user->id); + $showOrganization = count($memberOrganizationIds) > 1; + $currentOrganizationIds = $this->eventRepository->getOrganizationIdsForEvent($eventId); + + $formData = [ + 'title' => (string) ($event['title'] ?? ''), + 'startDate' => !empty($event['startDate']) ? date('Y-m-d', (int) $event['startDate']) : '', + 'endDate' => !empty($event['endDate']) ? date('Y-m-d', (int) $event['endDate']) : '', + 'addTime' => '1' === (string) ($event['addTime'] ?? ''), + 'startTime' => ('1' === (string) ($event['addTime'] ?? '') && !empty($event['startTime'])) ? date('H:i', (int) $event['startTime']) : '', + 'endTime' => $this->resolveFormEndTime($event), + 'location_id' => (int) ($event['location_id'] ?? 0), + 'type' => StringUtil::deserialize($event['type'] ?? null, true), + 'teaser' => (string) ($event['teaser'] ?? ''), + 'description' => (string) ($event['description'] ?? ''), + 'url' => (string) ($event['url'] ?? ''), + 'photographer' => (string) ($event['photographer'] ?? ''), + 'addImage' => '1' === (string) ($event['addImage'] ?? ''), + 'termsAccepted' => '1' === (string) ($event['termsAccepted'] ?? ''), + 'isSoldOut' => '1' === (string) ($event['isSoldOut'] ?? ''), + 'isCanceled' => '1' === (string) ($event['isCanceled'] ?? ''), + 'published' => '1' === (string) ($event['published'] ?? ''), + ]; + + if ($showOrganization) { + $allowedOrganizationIds = array_values(array_unique(array_map('intval', $memberOrganizationIds))); + $selectedOrganizationIds = array_values(array_intersect($currentOrganizationIds, $allowedOrganizationIds)); + + if ([] === $selectedOrganizationIds && [] !== $allowedOrganizationIds) { + $selectedOrganizationIds = [(int) $allowedOrganizationIds[0]]; + } + + $formData['organization_ids'] = $selectedOrganizationIds; + } + + $currentImagePath = null; + + if (!empty($event['singleSRC'])) { + $imageModel = FilesModel::findByUuid((string) $event['singleSRC']); + + if (null !== $imageModel) { + $currentImagePath = $imageModel->path; + } + } + + $form = $this->createForm(EventType::class, $formData, [ + 'location_choices' => $this->eventRepository->getLocationChoices(), + 'organization_choices' => $organizationChoices, + 'selected_organization_ids' => $showOrganization ? ($formData['organization_ids'] ?? []) : [], + 'show_organization' => $showOrganization, + 'terms_page_url' => $this->resolveTermsPageUrl($model), + ]); + $form->handleRequest($request); + + if ($form->isSubmitted()) { + $submittedData = (array) $form->getData(); + + if (!empty($submittedData['addImage']) && '' === trim((string) ($submittedData['photographer'] ?? ''))) { + $form->get('photographer')->addError(new FormError('Die Angabe des Urhebers ist notwendig. Ihnen muss eine Genehmigung zur Verwendung des Bildes vorliegen.')); + } + + if (!empty($submittedData['addTime']) && '' === trim((string) ($submittedData['startTime'] ?? ''))) { + $form->get('startTime')->addError(new FormError('Bitte geben Sie eine Startzeit an.')); + } + } + + if ($form->isSubmitted() && $form->isValid()) { + $submittedData = (array) $form->getData(); + + $selectedOrganizationIds = []; + + if ($showOrganization) { + $selectedOrganizationIds = array_map('intval', (array) $form->get('organization_ids')->getData()); + } elseif ([] !== $memberOrganizationIds) { + $selectedOrganizationIds = [(int) $memberOrganizationIds[0]]; + } + + $allowedOrganizationIds = array_values(array_unique(array_map('intval', $memberOrganizationIds))); + $selectedOrganizationIds = array_values(array_intersect($selectedOrganizationIds, $allowedOrganizationIds)); + + if ([] === $selectedOrganizationIds) { + if ($showOrganization) { + $form->get('organization_ids')->addError(new FormError('Bitte mindestens eine Organisation auswählen.')); + } else { + $form->addError(new FormError('Keine gültige Veranstalter-Zuordnung vorhanden.')); + } + } + + if ($form->isSubmitted() && !$form->isValid()) { + $template->set('form', $form->createView()); + $template->set('backUrl', $backUrl); + $template->set('requestToken', $this->container->get('contao.csrf.token_manager')->getDefaultTokenValue()); + $template->set('currentImagePath', $currentImagePath); + + return $template->getResponse(); + } + + if ($isCreateMode) { + $createdEventId = $this->eventRepository->createForMember( + (int) $user->id, + (int) ($model->frontendAuthorId ?? 0), + (int) ($model->frontendArchiveId ?? 0), + $submittedData, + $selectedOrganizationIds, + ); + + if (null === $createdEventId) { + $form->addError(new FormError('Die Veranstaltung konnte nicht erstellt werden.')); + $template->set('form', $form->createView()); + $template->set('backUrl', $backUrl); + $template->set('requestToken', $this->container->get('contao.csrf.token_manager')->getDefaultTokenValue()); + $template->set('currentImagePath', $currentImagePath); + + return $template->getResponse(); + } + + $eventId = $createdEventId; + } else { + if ($showOrganization) { + $this->eventRepository->assignEventToOrganizations($eventId, $selectedOrganizationIds); + } + + $this->eventRepository->update($eventId, $submittedData); + } + + $useImage = !empty($submittedData['addImage']); + $removeImage = '1' === (string) $request->request->get('remove_image', '0'); + $uploadedImage = $form->get('eventUpload')->getData(); + + if (!$useImage) { + $this->eventRepository->updateImageFields($eventId, false, null); + } elseif ($uploadedImage instanceof UploadedFile) { + $newImageUuid = $this->storeEventImage($uploadedImage, $model, $eventId); + + if (null !== $newImageUuid) { + $this->eventRepository->updateImageFields($eventId, true, $newImageUuid); + } + } elseif ($removeImage) { + $this->eventRepository->updateImageFields($eventId, true, null); + } + + if ($request->request->has('save_back')) { + return new RedirectResponse($backUrl); + } + + if ($isCreateMode) { + return new RedirectResponse($this->buildEventEditUrl($eventId, $backUrl)); + } + + return new RedirectResponse($request->getUri()); + } + + $template->set('form', $form->createView()); + $template->set('backUrl', $backUrl); + $template->set('requestToken', $this->container->get('contao.csrf.token_manager')->getDefaultTokenValue()); + $template->set('currentImagePath', $currentImagePath); + + return $template->getResponse(); + } + + private function storeEventImage(UploadedFile $uploadedFile, ModuleModel $model, int $eventId): ?string + { + if (empty($model->eventFolder)) { + return null; + } + + $folderModel = FilesModel::findByUuid((string) $model->eventFolder); + + if (null === $folderModel) { + return null; + } + + $organization = $this->eventRepository->findPrimaryOrganizationForEvent($eventId); + + if (null === $organization) { + return null; + } + + $titleSlug = strtolower(StringUtil::generateAlias((string) $organization['title'])); + $titleSlug = preg_replace('/[^a-z0-9-]+/', '-', $titleSlug ?? '') ?? ''; + $titleSlug = trim($titleSlug, '-'); + $titleSlug = substr($titleSlug, 0, 12); + + if ('' === $titleSlug) { + $titleSlug = 'organization'; + } + + $uploadBasePath = trim((string) $folderModel->path, '/'); + + if ('' === $uploadBasePath) { + return null; + } + + $targetRelativeDir = sprintf('%s/org-%d-%s', $uploadBasePath, (int) $organization['id'], $titleSlug); + $projectDir = (string) $this->getParameter('kernel.project_dir'); + $targetAbsoluteDir = rtrim($projectDir, '/').'/'.$targetRelativeDir; + + if (!is_dir($targetAbsoluteDir) && !mkdir($targetAbsoluteDir, 0775, true) && !is_dir($targetAbsoluteDir)) { + return null; + } + + $originalName = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME); + $safeName = preg_replace('/[^a-zA-Z0-9_-]+/', '-', (string) $originalName); + $safeName = trim((string) $safeName, '-'); + + if ('' === $safeName) { + $safeName = 'event-image'; + } + + $extension = $uploadedFile->guessExtension() ?: $uploadedFile->getClientOriginalExtension() ?: 'bin'; + $filename = sprintf('%s-%s.%s', $safeName, uniqid('', true), strtolower((string) $extension)); + + $uploadedFile->move($targetAbsoluteDir, $filename); + + $relativePath = $targetRelativeDir.'/'.$filename; + $fileModel = Dbafs::addResource($relativePath); + + return null !== $fileModel ? (string) $fileModel->uuid : null; + } + + private function resolveBackUrl(Request $request, ModuleModel $model): string + { + if ((int) ($model->listPage ?? 0) > 0) { + $listPage = $this->getContaoAdapter(PageModel::class)->findById((int) $model->listPage); + + if ($listPage instanceof PageModel) { + return $this->generateContentUrl($listPage); + } + } + + $ref = (string) $request->query->get('ref', ''); + + if ('' !== $ref) { + $decoded = base64_decode($ref, true); + + if (false !== $decoded && str_starts_with($decoded, '/')) { + return $decoded; + } + } + + $page = $this->getPageModel(); + + return $page instanceof PageModel ? $this->generateContentUrl($page) : '/'; + } + + private function resolveTermsPageUrl(ModuleModel $model): ?string + { + if ((int) ($model->termsPage ?? 0) <= 0) { + return null; + } + + $termsPage = $this->getContaoAdapter(PageModel::class)->findById((int) $model->termsPage); + + if (!$termsPage instanceof PageModel) { + return null; + } + + return $this->generateContentUrl($termsPage); + } + + private function buildEventEditUrl(int $eventId, string $backUrl): string + { + $page = $this->getPageModel(); + + if (!$page instanceof PageModel) { + return '/'; + } + + return $this->generateContentUrl($page, [ + 'event' => (string) $eventId, + 'ref' => base64_encode($backUrl), + ]); + } + + private function resolveFormEndTime(array $event): string + { + if ('1' !== (string) ($event['addTime'] ?? '')) { + return ''; + } + + $startTime = (int) ($event['startTime'] ?? 0); + $endTime = (int) ($event['endTime'] ?? 0); + + if ($endTime <= 0 || $startTime <= 0) { + return ''; + } + + if ($endTime === $startTime || $endTime - $startTime === 86399) { + return ''; + } + + return date('H:i', $endTime); + } +} diff --git a/src/Controller/Frontend/MemberEventsController.php b/src/Controller/Frontend/MemberEventsController.php new file mode 100644 index 0000000..ca48444 --- /dev/null +++ b/src/Controller/Frontend/MemberEventsController.php @@ -0,0 +1,133 @@ +getUser(); + + if (!$user instanceof FrontendUser) { + $template->set('upcomingEvents', []); + $template->set('pastEvents', []); + $template->set('isEditor', false); + + return $template->getResponse(); + } + + $isEditor = $this->isEditor($user); + $editPage = null; + + if ((int) ($model->editPage ?? 0) > 0) { + $editPage = $this->getContaoAdapter(PageModel::class)->findById((int) $model->editPage); + } + + $currentPage = $this->getPageModel(); + $backUrl = $currentPage instanceof PageModel ? $this->generateContentUrl($currentPage) : '/'; + + if ($isEditor && 'POST' === $request->getMethod() && $request->request->has('action')) { + $action = (string) $request->request->get('action', ''); + $eventId = (int) $request->request->get('event_id', 0); + + if (!in_array($action, ['create', 'toggle_published', 'duplicate', 'delete'], true)) { + return $template->getResponse(); + } + + if ('create' === $action && $editPage instanceof PageModel) { + return new RedirectResponse($this->generateContentUrl($editPage, [ + 'create' => '1', + 'ref' => base64_encode($backUrl), + ])); + } + + if ($eventId > 0 && $this->eventRepository->memberHasEvent((int) $user->id, $eventId)) { + if ('toggle_published' === $action) { + $this->eventRepository->togglePublished($eventId); + } elseif ('duplicate' === $action) { + $this->eventRepository->duplicate($eventId); + } elseif ('delete' === $action) { + $this->eventRepository->delete($eventId); + } + } + + return new RedirectResponse($request->getUri()); + } + + $events = $this->eventRepository->findByMemberId((int) $user->id); + + $upcomingItems = []; + $pastItems = []; + $today = strtotime('today'); + + foreach ($events as $event) { + $editUrl = null; + + if ($isEditor && $editPage instanceof PageModel) { + $editUrl = $this->generateContentUrl($editPage, [ + 'event' => (string) $event['id'], + 'ref' => base64_encode($backUrl), + ]); + } + + $item = [ + 'id' => (int) $event['id'], + 'title' => (string) ($event['title'] ?? ''), + 'startDate' => (int) ($event['startDate'] ?? 0), + 'published' => '1' === (string) ($event['published'] ?? ''), + 'editUrl' => $editUrl, + ]; + + if ((int) $item['startDate'] >= $today) { + $upcomingItems[] = $item; + } else { + $pastItems[] = $item; + } + } + + usort( + $upcomingItems, + static fn (array $a, array $b): int => ((int) $b['startDate']) <=> ((int) $a['startDate']), + ); + + usort( + $pastItems, + static fn (array $a, array $b): int => ((int) $b['startDate']) <=> ((int) $a['startDate']), + ); + + $template->set('upcomingEvents', $upcomingItems); + $template->set('pastEvents', $pastItems); + $template->set('isEditor', $isEditor); + $template->set('canCreateEvent', $isEditor && $editPage instanceof PageModel); + $template->set('requestToken', $this->container->get('contao.csrf.token_manager')->getDefaultTokenValue()); + + return $template->getResponse(); + } + + private function isEditor(FrontendUser $user): bool + { + $groups = is_array($user->groups) ? $user->groups : StringUtil::deserialize($user->groups, true); + + return in_array(1, array_map('intval', $groups), true); + } +} diff --git a/src/Controller/Frontend/MemberOrganizationsController.php b/src/Controller/Frontend/MemberOrganizationsController.php new file mode 100644 index 0000000..e70d7f1 --- /dev/null +++ b/src/Controller/Frontend/MemberOrganizationsController.php @@ -0,0 +1,77 @@ +getUser(); + + if (!$user instanceof FrontendUser) { + $template->set('organizations', []); + + return $template->getResponse(); + } + + $organizations = $this->organizationRepository->findByMemberId((int) $user->id); + $isEditor = $this->isEditor($user); + $editPage = null; + + if ((int) ($model->editPage ?? 0) > 0) { + $editPage = $this->getContaoAdapter(PageModel::class)->findById((int) $model->editPage); + } + + $currentPage = $this->getPageModel(); + $backUrl = $currentPage instanceof PageModel ? $this->generateContentUrl($currentPage) : '/'; + + $items = []; + + foreach ($organizations as $organization) { + $editUrl = null; + + if ($isEditor && $editPage instanceof PageModel) { + $editUrl = $this->generateContentUrl($editPage, [ + 'organization' => (string) $organization['id'], + 'ref' => base64_encode($backUrl), + ]); + } + + $items[] = [ + 'id' => (int) $organization['id'], + 'title' => (string) ($organization['title'] ?? ''), + 'editUrl' => $editUrl, + ]; + } + + $template->set('organizations', $items); + + return $template->getResponse(); + } + + private function isEditor(FrontendUser $user): bool + { + $groups = is_array($user->groups) ? $user->groups : StringUtil::deserialize($user->groups, true); + + return in_array(1, array_map('intval', $groups), true); + } +} diff --git a/src/Controller/Frontend/OrganizationEditController.php b/src/Controller/Frontend/OrganizationEditController.php new file mode 100644 index 0000000..41b47e9 --- /dev/null +++ b/src/Controller/Frontend/OrganizationEditController.php @@ -0,0 +1,212 @@ +getUser(); + $backUrl = $this->resolveBackUrl($request, $model); + $organizationParam = $request->query->get('organization'); + + if (null === $organizationParam) { + $template->set('error', 'Ungültiger Aufruf: Parameter "organization" fehlt.'); + $template->set('backUrl', $backUrl); + + return $template->getResponse(); + } + + if (!$user instanceof FrontendUser) { + $template->set('error', 'Bitte zuerst als Mitglied einloggen.'); + $template->set('backUrl', $backUrl); + + return $template->getResponse(); + } + + $organizationId = (int) $organizationParam; + + if ($organizationId <= 0 || !$this->organizationRepository->memberHasOrganization((int) $user->id, $organizationId)) { + $template->set('error', 'Keine Berechtigung für diese Organisation oder ungültige ID.'); + $template->set('backUrl', $backUrl); + + return $template->getResponse(); + } + + $organization = $this->organizationRepository->findById($organizationId); + + if (null === $organization) { + $template->set('error', 'Organisation nicht gefunden.'); + $template->set('backUrl', $backUrl); + + return $template->getResponse(); + } + + $formData = [ + 'title' => (string) ($organization['title'] ?? ''), + 'street' => (string) ($organization['street'] ?? ''), + 'postal' => (string) ($organization['postal'] ?? ''), + 'city' => (string) ($organization['city'] ?? ''), + 'state' => (string) ($organization['state'] ?? ''), + 'country' => (string) ($organization['country'] ?? ''), + 'phone' => (string) ($organization['phone'] ?? ''), + 'email' => (string) ($organization['email'] ?? ''), + 'website' => (string) ($organization['website'] ?? ''), + 'description' => (string) ($organization['description'] ?? ''), + 'type' => StringUtil::deserialize($organization['type'] ?? null, true), + ]; + + $currentLogoPath = null; + + if (!empty($organization['logo'])) { + $logoModel = FilesModel::findByUuid((string) $organization['logo']); + + if (null !== $logoModel) { + $currentLogoPath = $logoModel->path; + } + } + + $form = $this->createForm(OrganizationType::class, $formData); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $submittedData = (array) $form->getData(); + + $this->organizationRepository->update($organizationId, $submittedData); + + $deleteLogo = '1' === (string) $request->request->get('remove_logo', '0'); + $uploadedLogo = $form->get('logoUpload')->getData(); + + if ($uploadedLogo instanceof UploadedFile) { + $newLogoUuid = $this->storeOrganizationLogo( + $uploadedLogo, + $model, + $organizationId, + (string) ($submittedData['title'] ?? ''), + ); + + if (null !== $newLogoUuid) { + $this->organizationRepository->updateLogo($organizationId, $newLogoUuid); + } + } elseif ($deleteLogo) { + $this->organizationRepository->updateLogo($organizationId, null); + } + + if ($request->request->has('save_back')) { + return new RedirectResponse($backUrl); + } + + return new RedirectResponse($request->getUri()); + } + + $template->set('form', $form->createView()); + $template->set('backUrl', $backUrl); + $template->set('requestToken', $this->container->get('contao.csrf.token_manager')->getDefaultTokenValue()); + $template->set('currentLogoPath', $currentLogoPath); + + return $template->getResponse(); + } + + private function storeOrganizationLogo(UploadedFile $uploadedFile, ModuleModel $model, int $organizationId, string $organizationTitle): ?string + { + if (empty($model->logoFolder)) { + return null; + } + + $folderModel = FilesModel::findByUuid((string) $model->logoFolder); + + if (null === $folderModel) { + return null; + } + + $uploadBasePath = trim((string) $folderModel->path, '/'); + + if ('' === $uploadBasePath) { + return null; + } + + $titleSlug = strtolower(StringUtil::generateAlias($organizationTitle)); + $titleSlug = preg_replace('/[^a-z0-9-]+/', '-', $titleSlug ?? '') ?? ''; + $titleSlug = trim($titleSlug, '-'); + $titleSlug = substr($titleSlug, 0, 12); + + if ('' === $titleSlug) { + $titleSlug = 'organization'; + } + + $targetRelativeDir = sprintf('%s/org-%d-%s', $uploadBasePath, $organizationId, $titleSlug); + $projectDir = (string) $this->getParameter('kernel.project_dir'); + $targetAbsoluteDir = rtrim($projectDir, '/').'/'.$targetRelativeDir; + + if (!is_dir($targetAbsoluteDir) && !mkdir($targetAbsoluteDir, 0775, true) && !is_dir($targetAbsoluteDir)) { + return null; + } + + $originalName = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME); + $safeName = preg_replace('/[^a-zA-Z0-9_-]+/', '-', (string) $originalName); + $safeName = trim((string) $safeName, '-'); + + if ('' === $safeName) { + $safeName = 'logo'; + } + + $extension = $uploadedFile->guessExtension() ?: $uploadedFile->getClientOriginalExtension() ?: 'bin'; + $filename = sprintf('%s-%s.%s', $safeName, uniqid('', true), strtolower((string) $extension)); + + $uploadedFile->move($targetAbsoluteDir, $filename); + + $relativePath = $targetRelativeDir.'/'.$filename; + $fileModel = Dbafs::addResource($relativePath); + + return null !== $fileModel ? (string) $fileModel->uuid : null; + } + + private function resolveBackUrl(Request $request, ModuleModel $model): string + { + if ((int) ($model->listPage ?? 0) > 0) { + $listPage = $this->getContaoAdapter(PageModel::class)->findById((int) $model->listPage); + + if ($listPage instanceof PageModel) { + return $this->generateContentUrl($listPage); + } + } + + $ref = (string) $request->query->get('ref', ''); + + if ('' !== $ref) { + $decoded = base64_decode($ref, true); + + if (false !== $decoded && str_starts_with($decoded, '/')) { + return $decoded; + } + } + + $page = $this->getPageModel(); + + return $page instanceof PageModel ? $this->generateContentUrl($page) : '/'; + } +} diff --git a/src/DependencyInjection/MummertMediaEventManagerExtension.php b/src/DependencyInjection/MummertMediaEventManagerExtension.php new file mode 100644 index 0000000..ac18ccb --- /dev/null +++ b/src/DependencyInjection/MummertMediaEventManagerExtension.php @@ -0,0 +1,19 @@ +load('services.yaml'); + } +} diff --git a/src/EventListener/DataContainer/SetDateAddedCallback.php b/src/EventListener/DataContainer/SetDateAddedCallback.php new file mode 100644 index 0000000..94a1e94 --- /dev/null +++ b/src/EventListener/DataContainer/SetDateAddedCallback.php @@ -0,0 +1,23 @@ +activeRecord && (int) ($dc->activeRecord->dateAdded ?? 0) > 0) { + return $values; + } + + if (!isset($values['dateAdded']) || 0 === (int) $values['dateAdded']) { + $values['dateAdded'] = time(); + } + + return $values; + } +} diff --git a/src/Form/EventType.php b/src/Form/EventType.php new file mode 100644 index 0000000..caa43eb --- /dev/null +++ b/src/Form/EventType.php @@ -0,0 +1,206 @@ +Nutzungsbedingungen zu.', + htmlspecialchars($options['terms_page_url'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + ); + } + + $showOrganizationSelect = true === $options['show_organization']; + + $builder + ->add('title', TextType::class, [ + 'label' => 'Titel', + 'required' => true, + ]) + ->add('startDate', DateType::class, [ + 'label' => 'Startdatum', + 'required' => false, + 'widget' => 'single_text', + 'input' => 'string', + 'html5' => false, + 'format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'js-flatpickr-date', + ], + ]) + ->add('endDate', DateType::class, [ + 'label' => 'Enddatum', + 'required' => false, + 'widget' => 'single_text', + 'input' => 'string', + 'html5' => false, + 'format' => 'yyyy-MM-dd', + 'help' => 'Lassen Sie das Feld leer, um ein eintägiges Event zu erstellen.', + 'attr' => [ + 'class' => 'js-flatpickr-date', + ], + ]) + ->add('addTime', CheckboxType::class, [ + 'label' => 'Dem Event eine Start- und Endzeit hinzufügen.', + 'required' => false, + 'attr' => [ + 'class' => 'js-add-time-toggle', + ], + ]) + ->add('startTime', TimeType::class, [ + 'label' => 'Startzeit', + 'required' => false, + 'input' => 'string', + 'widget' => 'single_text', + 'html5' => false, + 'input_format' => 'H:i', + 'attr' => [ + 'class' => 'js-flatpickr-time', + ], + ]) + ->add('endTime', TimeType::class, [ + 'label' => 'Endzeit', + 'required' => false, + 'input' => 'string', + 'widget' => 'single_text', + 'html5' => false, + 'input_format' => 'H:i', + 'help' => 'Lassen Sie das Feld leer, um ein Event mit offenem Ende zu erstellen.', + 'attr' => [ + 'class' => 'js-flatpickr-time', + ], + ]); + + if ($showOrganizationSelect) { + $builder->add('organization_ids', ChoiceType::class, [ + 'label' => 'Veranstalter', + 'choices' => $options['organization_choices'], + 'required' => true, + 'mapped' => false, + 'multiple' => true, + 'data' => $options['selected_organization_ids'], + 'attr' => [ + 'class' => 'js-organization-choice', + 'data-placeholder' => 'Veranstalter suchen …', + ], + ]); + } + + $builder + ->add('location_id', ChoiceType::class, [ + 'label' => 'Veranstaltungsort', + 'choices' => $options['location_choices'], + 'required' => false, + 'choice_value' => static fn ($value) => null !== $value ? (string) $value : '', + 'placeholder' => 'Bitte auswählen', + 'attr' => [ + 'class' => 'js-location-choice', + 'data-placeholder' => 'Veranstaltungsort suchen …', + ], + ]) + ->add('type', ChoiceType::class, [ + 'label' => 'Typ', + 'choices' => [ + 'Unterkunft' => 'accommodation', + 'Einkaufen' => 'shopping', + 'Kultur' => 'culture', + ], + 'multiple' => true, + 'required' => false, + 'attr' => [ + 'class' => 'js-event-type-choice', + 'data-placeholder' => 'Typen suchen …', + ], + ]) + ->add('teaser', TextareaType::class, ['label' => 'Teaser', 'required' => false]) + ->add('description', TextareaType::class, ['label' => 'Beschreibung', 'required' => false]) + ->add('url', UrlType::class, [ + 'label' => 'Link (extern)', + 'required' => false, + 'default_protocol' => 'https', + 'invalid_message' => 'Bitte eine gültige URL mit http:// oder https:// eingeben (z. B. https://example.org).', + 'constraints' => [ + new Url([ + 'protocols' => ['http', 'https'], + 'message' => 'Bitte eine gültige URL mit http:// oder https:// eingeben (z. B. https://example.org).', + ]), + ], + ]) + ->add('addImage', CheckboxType::class, [ + 'label' => 'Dem Event ein Bild hinzufügen', + 'required' => false, + 'attr' => [ + 'class' => 'js-add-image-toggle', + ], + ]) + ->add('eventUpload', FileType::class, [ + 'label' => 'Bild hochladen', + 'required' => false, + 'mapped' => false, + 'attr' => [ + 'class' => 'js-event-image-upload', + 'accept' => 'image/*', + ], + ]) + ->add('photographer', TextType::class, [ + 'label' => 'Urheber/Fotograf', + 'required' => false, + 'attr' => [ + 'class' => 'js-photographer', + 'maxlength' => '255', + ], + ]) + ->add('termsAccepted', CheckboxType::class, [ + 'label' => $termsLabel, + 'label_html' => true, + 'required' => true, + ]) + ->add('isSoldOut', CheckboxType::class, [ + 'label' => 'Diese Veranstaltung ist ausverkauft.', + 'required' => false, + ]) + ->add('isCanceled', CheckboxType::class, [ + 'label' => 'Diese Veranstaltung wurde abgesagt.', + 'required' => false, + ]) + ->add('published', CheckboxType::class, ['label' => 'Veröffentlicht', 'required' => false]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'csrf_protection' => true, + 'location_choices' => [], + 'organization_choices' => [], + 'selected_organization_ids' => [], + 'show_organization' => false, + 'terms_page_url' => null, + ]); + + $resolver->setAllowedTypes('location_choices', 'array'); + $resolver->setAllowedTypes('organization_choices', 'array'); + $resolver->setAllowedTypes('selected_organization_ids', 'array'); + $resolver->setAllowedTypes('show_organization', 'bool'); + $resolver->setAllowedTypes('terms_page_url', ['null', 'string']); + } +} diff --git a/src/Form/OrganizationType.php b/src/Form/OrganizationType.php new file mode 100644 index 0000000..f7da023 --- /dev/null +++ b/src/Form/OrganizationType.php @@ -0,0 +1,54 @@ +add('title', TextType::class, ['label' => 'Titel', 'required' => true]) + ->add('street', TextType::class, ['label' => 'Straße', 'required' => false]) + ->add('postal', TextType::class, ['label' => 'PLZ', 'required' => false]) + ->add('city', TextType::class, ['label' => 'Ort', 'required' => false]) + ->add('state', TextType::class, ['label' => 'Bundesland', 'required' => false]) + ->add('country', TextType::class, ['label' => 'Land', 'required' => false]) + ->add('phone', TextType::class, ['label' => 'Telefon', 'required' => false]) + ->add('email', EmailType::class, ['label' => 'E-Mail', 'required' => false]) + ->add('website', TextType::class, ['label' => 'Webseite', 'required' => false]) + ->add('description', TextareaType::class, ['label' => 'Beschreibung', 'required' => false]) + ->add('type', ChoiceType::class, [ + 'label' => 'Typ', + 'choices' => [ + 'Unterkunft' => 'accommodation', + 'Einkaufen' => 'shopping', + 'Kultur' => 'culture', + ], + 'multiple' => true, + 'required' => false, + 'attr' => [ + 'class' => 'js-organization-type-choice', + 'data-placeholder' => 'Typ auswählen …', + ], + ]) + ->add('logoUpload', FileType::class, [ + 'label' => 'Logo hochladen', + 'required' => false, + 'mapped' => false, + 'attr' => [ + 'class' => 'js-logo-upload', + 'accept' => 'image/*', + ], + ]); + } +} diff --git a/src/Model/LocationModel.php b/src/Model/LocationModel.php new file mode 100644 index 0000000..7367220 --- /dev/null +++ b/src/Model/LocationModel.php @@ -0,0 +1,12 @@ +> + */ + public function findByMemberId(int $memberId): array + { + return $this->connection->createQueryBuilder() + ->select('DISTINCT e.*') + ->from('tl_calendar_events', 'e') + ->innerJoin('e', 'tl_calendar_events_organization', 'ceo', 'ceo.event_id = e.id') + ->innerJoin('ceo', 'tl_member_organization', 'mo', 'mo.organization_id = ceo.organization_id') + ->where('mo.member_id = :memberId') + ->setParameter('memberId', $memberId, ParameterType::INTEGER) + ->orderBy('e.startDate', 'DESC') + ->executeQuery() + ->fetchAllAssociative(); + } + + public function memberHasEvent(int $memberId, int $eventId): bool + { + $exists = $this->connection->createQueryBuilder() + ->select('1') + ->from('tl_calendar_events', 'e') + ->innerJoin('e', 'tl_calendar_events_organization', 'ceo', 'ceo.event_id = e.id') + ->innerJoin('ceo', 'tl_member_organization', 'mo', 'mo.organization_id = ceo.organization_id') + ->where('e.id = :eventId') + ->andWhere('mo.member_id = :memberId') + ->setParameter('eventId', $eventId, ParameterType::INTEGER) + ->setParameter('memberId', $memberId, ParameterType::INTEGER) + ->setMaxResults(1) + ->executeQuery() + ->fetchOne(); + + return false !== $exists; + } + + public function togglePublished(int $eventId): void + { + $published = $this->connection->createQueryBuilder() + ->select('published') + ->from('tl_calendar_events') + ->where('id = :id') + ->setParameter('id', $eventId, ParameterType::INTEGER) + ->setMaxResults(1) + ->executeQuery() + ->fetchOne(); + + $isPublished = '1' === (string) $published; + + $this->connection->update( + 'tl_calendar_events', + [ + 'published' => $isPublished ? 0 : 1, + 'tstamp' => time(), + ], + ['id' => $eventId], + ['id' => ParameterType::INTEGER], + ); + } + + public function duplicate(int $eventId): ?int + { + $row = $this->findById($eventId); + + if (null === $row) { + return null; + } + + unset($row['id']); + + if (array_key_exists('title', $row)) { + $row['title'] = trim((string) $row['title'].' (Kopie)'); + } + + if (array_key_exists('alias', $row)) { + $row['alias'] = ''; + } + + if (array_key_exists('published', $row)) { + $row['published'] = 0; + } + + $now = time(); + + if (array_key_exists('dateAdded', $row)) { + $row['dateAdded'] = $now; + } + + if (array_key_exists('tstamp', $row)) { + $row['tstamp'] = $now; + } + + $this->connection->insert('tl_calendar_events', $row); + + $newEventId = (int) $this->connection->lastInsertId(); + + $organizationIds = $this->connection->createQueryBuilder() + ->select('organization_id') + ->from('tl_calendar_events_organization') + ->where('event_id = :eventId') + ->setParameter('eventId', $eventId, ParameterType::INTEGER) + ->executeQuery() + ->fetchFirstColumn(); + + foreach ($organizationIds as $organizationId) { + $this->connection->insert( + 'tl_calendar_events_organization', + [ + 'event_id' => $newEventId, + 'organization_id' => (int) $organizationId, + ], + [ + 'event_id' => ParameterType::INTEGER, + 'organization_id' => ParameterType::INTEGER, + ], + ); + } + + return $newEventId; + } + + public function createDraftForMember(int $memberId, int $authorId, int $archiveId): ?int + { + $resolvedArchiveId = $this->resolveArchiveId($archiveId); + + if ($resolvedArchiveId <= 0) { + return null; + } + + $organizationId = $this->connection->createQueryBuilder() + ->select('organization_id') + ->from('tl_member_organization') + ->where('member_id = :memberId') + ->setParameter('memberId', $memberId, ParameterType::INTEGER) + ->orderBy('organization_id', 'ASC') + ->setMaxResults(1) + ->executeQuery() + ->fetchOne(); + + if (false === $organizationId) { + return null; + } + + $now = time(); + + $this->connection->insert( + 'tl_calendar_events', + [ + 'pid' => $resolvedArchiveId, + 'author' => max(0, $authorId), + 'title' => '', + 'alias' => '', + 'startDate' => 0, + 'endDate' => null, + 'published' => 0, + 'tstamp' => $now, + ], + [ + 'pid' => ParameterType::INTEGER, + 'author' => ParameterType::INTEGER, + ], + ); + + $eventId = (int) $this->connection->lastInsertId(); + + if ($eventId <= 0) { + return null; + } + + $this->connection->insert( + 'tl_calendar_events_organization', + [ + 'tstamp' => $now, + 'event_id' => $eventId, + 'organization_id' => (int) $organizationId, + ], + [ + 'event_id' => ParameterType::INTEGER, + 'organization_id' => ParameterType::INTEGER, + ], + ); + + return $eventId; + } + + /** @return array{authorId:int,archiveId:int} */ + public function findCreationDefaultsByListPage(int $listPageId): array + { + if ($listPageId <= 0) { + return ['authorId' => 0, 'archiveId' => 0]; + } + + $row = $this->connection->createQueryBuilder() + ->select('frontendAuthorId', 'frontendArchiveId') + ->from('tl_module') + ->where('type = :type') + ->andWhere('listPage = :listPage') + ->setParameter('type', 'event_edit') + ->setParameter('listPage', $listPageId, ParameterType::INTEGER) + ->orderBy('id', 'ASC') + ->setMaxResults(1) + ->executeQuery() + ->fetchAssociative(); + + if (false === $row || null === $row) { + return ['authorId' => 0, 'archiveId' => 0]; + } + + return [ + 'authorId' => (int) ($row['frontendAuthorId'] ?? 0), + 'archiveId' => (int) ($row['frontendArchiveId'] ?? 0), + ]; + } + + private function resolveArchiveId(int $archiveId): int + { + if ($archiveId > 0) { + $exists = $this->connection->createQueryBuilder() + ->select('id') + ->from('tl_calendar') + ->where('id = :id') + ->setParameter('id', $archiveId, ParameterType::INTEGER) + ->setMaxResults(1) + ->executeQuery() + ->fetchOne(); + + if (false !== $exists) { + return $archiveId; + } + } + + $fallback = $this->connection->createQueryBuilder() + ->select('id') + ->from('tl_calendar') + ->orderBy('id', 'ASC') + ->setMaxResults(1) + ->executeQuery() + ->fetchOne(); + + return false !== $fallback ? (int) $fallback : 0; + } + + public function delete(int $eventId): void + { + $this->connection->delete( + 'tl_calendar_events_organization', + ['event_id' => $eventId], + ['event_id' => ParameterType::INTEGER], + ); + + $this->connection->delete( + 'tl_calendar_events', + ['id' => $eventId], + ['id' => ParameterType::INTEGER], + ); + } + + /** @return array|null */ + public function findById(int $eventId): ?array + { + $row = $this->connection->createQueryBuilder() + ->select('*') + ->from('tl_calendar_events') + ->where('id = :id') + ->setParameter('id', $eventId, ParameterType::INTEGER) + ->setMaxResults(1) + ->executeQuery() + ->fetchAssociative(); + + return false === $row ? null : $row; + } + + /** @return array{id:int,title:string}|null */ + public function findPrimaryOrganizationForEvent(int $eventId): ?array + { + $row = $this->connection->createQueryBuilder() + ->select('o.id', 'o.title') + ->from('tl_calendar_events_organization', 'ceo') + ->innerJoin('ceo', 'tl_organization', 'o', 'o.id = ceo.organization_id') + ->where('ceo.event_id = :eventId') + ->setParameter('eventId', $eventId, ParameterType::INTEGER) + ->orderBy('ceo.organization_id', 'ASC') + ->setMaxResults(1) + ->executeQuery() + ->fetchAssociative(); + + if (false === $row || null === $row) { + return null; + } + + return [ + 'id' => (int) $row['id'], + 'title' => (string) ($row['title'] ?? ''), + ]; + } + + /** @return array */ + public function getOrganizationIdsForEvent(int $eventId): array + { + $rows = $this->connection->createQueryBuilder() + ->select('organization_id') + ->from('tl_calendar_events_organization') + ->where('event_id = :eventId') + ->setParameter('eventId', $eventId, ParameterType::INTEGER) + ->orderBy('organization_id', 'ASC') + ->executeQuery() + ->fetchFirstColumn(); + + return array_values(array_unique(array_map('intval', $rows))); + } + + /** + * @return array + */ + public function getLocationChoices(): array + { + $rows = $this->connection->createQueryBuilder() + ->select('id', 'title') + ->from('tl_location') + ->where('published = :published') + ->setParameter('published', '1') + ->orderBy('title', 'ASC') + ->executeQuery() + ->fetchAllAssociative(); + + $choices = []; + + foreach ($rows as $row) { + $choices[(string) $row['title']] = (int) $row['id']; + } + + return $choices; + } + + /** + * @return array + */ + public function getOrganizationChoicesForMember(int $memberId): array + { + $rows = $this->connection->createQueryBuilder() + ->select('o.id', 'o.title') + ->from('tl_member_organization', 'mo') + ->innerJoin('mo', 'tl_organization', 'o', 'o.id = mo.organization_id') + ->where('mo.member_id = :memberId') + ->setParameter('memberId', $memberId, ParameterType::INTEGER) + ->orderBy('o.title', 'ASC') + ->executeQuery() + ->fetchAllAssociative(); + + $choices = []; + $seenLabels = []; + + foreach ($rows as $row) { + $id = (int) $row['id']; + $label = trim((string) ($row['title'] ?? '')); + + if ('' === $label) { + $label = 'Organisation '.$id; + } + + if (isset($seenLabels[$label])) { + $label .= ' (#'.$id.')'; + } + + $seenLabels[$label] = true; + $choices[$label] = $id; + } + + return $choices; + } + + /** @return array */ + public function getOrganizationIdsForMember(int $memberId): array + { + $ids = $this->connection->createQueryBuilder() + ->select('organization_id') + ->from('tl_member_organization') + ->where('member_id = :memberId') + ->setParameter('memberId', $memberId, ParameterType::INTEGER) + ->orderBy('organization_id', 'ASC') + ->executeQuery() + ->fetchFirstColumn(); + + return array_values(array_unique(array_map('intval', $ids))); + } + + /** @param array $organizationIds */ + public function assignEventToOrganizations(int $eventId, array $organizationIds): void + { + $this->connection->delete( + 'tl_calendar_events_organization', + ['event_id' => $eventId], + ['event_id' => ParameterType::INTEGER], + ); + + $organizationIds = array_values(array_unique(array_map('intval', $organizationIds))); + + foreach ($organizationIds as $organizationId) { + if ($organizationId <= 0) { + continue; + } + + $this->connection->insert( + 'tl_calendar_events_organization', + [ + 'tstamp' => time(), + 'event_id' => $eventId, + 'organization_id' => $organizationId, + ], + [ + 'event_id' => ParameterType::INTEGER, + 'organization_id' => ParameterType::INTEGER, + ], + ); + } + } + + /** + * @param array $data + */ + public function update(int $eventId, array $data): void + { + $eventData = $this->buildEventData($data); + + $this->connection->update( + 'tl_calendar_events', + $eventData, + ['id' => $eventId], + [ + 'id' => ParameterType::INTEGER, + ], + ); + } + + /** + * @param array $data + * @param array $organizationIds + */ + public function createForMember(int $memberId, int $authorId, int $archiveId, array $data, array $organizationIds): ?int + { + if ($memberId <= 0) { + return null; + } + + $resolvedArchiveId = $this->resolveArchiveId($archiveId); + + if ($resolvedArchiveId <= 0) { + return null; + } + + $eventData = $this->buildEventData($data); + $eventData['pid'] = $resolvedArchiveId; + $eventData['author'] = max(0, $authorId); + + $this->connection->insert( + 'tl_calendar_events', + $eventData, + [ + 'pid' => ParameterType::INTEGER, + 'author' => ParameterType::INTEGER, + ], + ); + + $eventId = (int) $this->connection->lastInsertId(); + + if ($eventId <= 0) { + return null; + } + + $memberOrganizationIds = $this->getOrganizationIdsForMember($memberId); + $allowedOrganizationIds = array_values(array_intersect(array_map('intval', $organizationIds), array_map('intval', $memberOrganizationIds))); + + if ([] === $allowedOrganizationIds) { + $this->connection->delete('tl_calendar_events', ['id' => $eventId], ['id' => ParameterType::INTEGER]); + + return null; + } + + $this->assignEventToOrganizations($eventId, $allowedOrganizationIds); + + return $eventId; + } + + /** + * @param array $data + * + * @return array + */ + private function buildEventData(array $data): array + { + $startDate = !empty($data['startDate']) ? strtotime((string) $data['startDate']) : false; + $endDate = !empty($data['endDate']) ? strtotime((string) $data['endDate']) : false; + $url = trim((string) ($data['url'] ?? '')); + $addTime = !empty($data['addTime']); + $alias = $this->buildAlias($startDate, (string) ($data['title'] ?? '')); + + $startDateTimestamp = false !== $startDate ? (int) $startDate : 0; + $endDateTimestamp = false !== $endDate ? (int) $endDate : 0; + $timeBaseTimestamp = $endDateTimestamp > 0 ? $endDateTimestamp : $startDateTimestamp; + + $startTimeTimestamp = $startDateTimestamp; + $endTimeTimestamp = $timeBaseTimestamp > 0 ? $timeBaseTimestamp + 86399 : 0; + + if ($addTime) { + $startTimeCandidate = $this->combineDateAndTime($startDateTimestamp, (string) ($data['startTime'] ?? '')); + $startTimeTimestamp = null !== $startTimeCandidate ? $startTimeCandidate : $startDateTimestamp; + + $endTimeCandidate = $this->combineDateAndTime($timeBaseTimestamp, (string) ($data['endTime'] ?? '')); + $endTimeTimestamp = null !== $endTimeCandidate ? $endTimeCandidate : $startTimeTimestamp; + } + + return [ + 'title' => $data['title'] ?? '', + 'alias' => $alias, + 'startDate' => false !== $startDate ? $startDate : 0, + 'endDate' => false !== $endDate ? $endDate : null, + 'location_id' => (int) ($data['location_id'] ?? 0), + 'addTime' => $addTime ? 1 : 0, + 'startTime' => $startTimeTimestamp, + 'endTime' => $endTimeTimestamp, + 'type' => serialize($data['type'] ?? []), + 'teaser' => $data['teaser'] ?? null, + 'description' => $data['description'] ?? null, + 'url' => $url, + 'source' => '' !== $url ? 'external' : 'default', + 'photographer' => $data['photographer'] ?? '', + 'addImage' => !empty($data['addImage']) ? 1 : 0, + 'termsAccepted' => !empty($data['termsAccepted']) ? '1' : '', + 'isSoldOut' => !empty($data['isSoldOut']) ? '1' : '', + 'isCanceled' => !empty($data['isCanceled']) ? '1' : '', + 'published' => !empty($data['published']) ? 1 : 0, + 'tstamp' => time(), + ]; + } + + private function buildAlias(false|int $startDate, string $title): string + { + if (false === $startDate || $startDate <= 0) { + return ''; + } + + $normalizedTitle = trim($title); + + if ('' === $normalizedTitle) { + return ''; + } + + $normalizedTitle = strtolower($normalizedTitle); + $transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalizedTitle); + + if (false !== $transliterated) { + $normalizedTitle = strtolower($transliterated); + } + + $normalizedTitle = preg_replace('/[^a-z0-9]+/', '', $normalizedTitle) ?? ''; + $normalizedTitle = substr($normalizedTitle, 0, 20); + + if ('' === $normalizedTitle) { + return ''; + } + + return date('Y-m-d', $startDate).'_'.$normalizedTitle; + } + + private function combineDateAndTime(int $dateTimestamp, string $time): ?int + { + if ($dateTimestamp <= 0) { + return null; + } + + $normalizedTime = trim($time); + + if (!preg_match('/^([01]\d|2[0-3]):([0-5]\d)$/', $normalizedTime, $matches)) { + return null; + } + + $hours = (int) $matches[1]; + $minutes = (int) $matches[2]; + + return $dateTimestamp + ($hours * 3600) + ($minutes * 60); + } + + public function updateImageFields(int $eventId, bool $addImage, ?string $singleSrcUuid): void + { + $types = [ + 'id' => ParameterType::INTEGER, + ]; + + if (null !== $singleSrcUuid) { + $types['singleSRC'] = ParameterType::BINARY; + } + + $this->connection->update( + 'tl_calendar_events', + [ + 'addImage' => $addImage ? 1 : 0, + 'singleSRC' => $singleSrcUuid, + 'tstamp' => time(), + ], + ['id' => $eventId], + $types, + ); + } +} diff --git a/src/Service/OrganizationRepository.php b/src/Service/OrganizationRepository.php new file mode 100644 index 0000000..39ced1a --- /dev/null +++ b/src/Service/OrganizationRepository.php @@ -0,0 +1,112 @@ +> + */ + public function findByMemberId(int $memberId): array + { + return $this->connection->createQueryBuilder() + ->select('o.*') + ->from('tl_organization', 'o') + ->innerJoin('o', 'tl_member_organization', 'mo', 'mo.organization_id = o.id') + ->where('mo.member_id = :memberId') + ->setParameter('memberId', $memberId, ParameterType::INTEGER) + ->orderBy('o.title', 'ASC') + ->executeQuery() + ->fetchAllAssociative(); + } + + public function memberHasOrganization(int $memberId, int $organizationId): bool + { + $exists = $this->connection->createQueryBuilder() + ->select('1') + ->from('tl_member_organization', 'mo') + ->where('mo.member_id = :memberId') + ->andWhere('mo.organization_id = :organizationId') + ->setParameter('memberId', $memberId, ParameterType::INTEGER) + ->setParameter('organizationId', $organizationId, ParameterType::INTEGER) + ->setMaxResults(1) + ->executeQuery() + ->fetchOne(); + + return false !== $exists; + } + + /** @return array|null */ + public function findById(int $organizationId): ?array + { + $row = $this->connection->createQueryBuilder() + ->select('*') + ->from('tl_organization') + ->where('id = :id') + ->setParameter('id', $organizationId, ParameterType::INTEGER) + ->setMaxResults(1) + ->executeQuery() + ->fetchAssociative(); + + return false === $row ? null : $row; + } + + /** + * @param array $data + */ + public function update(int $organizationId, array $data): void + { + $this->connection->update( + 'tl_organization', + [ + 'title' => $data['title'] ?? '', + 'street' => $data['street'] ?? '', + 'postal' => $data['postal'] ?? '', + 'city' => $data['city'] ?? '', + 'state' => $data['state'] ?? '', + 'country' => $data['country'] ?? '', + 'phone' => $data['phone'] ?? '', + 'email' => $data['email'] ?? '', + 'website' => $data['website'] ?? '', + 'description' => $data['description'] ?? null, + 'type' => serialize($data['type'] ?? []), + 'tstamp' => time(), + ], + ['id' => $organizationId], + [ + 'id' => ParameterType::INTEGER, + ], + ); + } + + public function updateLogo(int $organizationId, ?string $logoUuid): void + { + $types = [ + 'id' => ParameterType::INTEGER, + ]; + + if (null !== $logoUuid) { + $types['logo'] = ParameterType::BINARY; + } + + $this->connection->update( + 'tl_organization', + [ + 'logo' => $logoUuid, + 'tstamp' => time(), + ], + ['id' => $organizationId], + $types, + ); + } +}