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) }}
+
+ {{ form_row(form.startTime) }}
+ {{ form_row(form.endTime) }}
+
+ {% 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.eventUpload) }}
+ {{ form_row(form.photographer) }}
+
Die Angabe des Urhebers ist notwendig. Ihnen muss eine Genehmigung zur Verwendung des Bildes vorliegen.
+
+
+ {{ 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,
+ );
+ }
+}