diff --git a/.github/workflows/docker-base.yml b/.github/workflows/docker-base.yml index 9a25b7a61..dbe3a2f78 100644 --- a/.github/workflows/docker-base.yml +++ b/.github/workflows/docker-base.yml @@ -45,6 +45,7 @@ jobs: org.opencontainers.image.vendor=BMLT org.opencontainers.image.created={{date 'YYYY-MM-DDTHH:mm:ssZ'}} org.opencontainers.image.version=${{ matrix.php_version }} + org.opencontainers.image.revision=${{ github.sha }} php.version=${{ matrix.php_version }} - name: Build and push Base diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3713f05d9..cf2f8e290 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,6 +86,47 @@ You should now be able to set breakpoints, launch this debug configuration, and This works exactly as described in the [vitest documentation](https://v0.vitest.dev/guide/debugging.html). Set any breakpoints, launch a new JavaScript Debug Terminal, and run `npm run test`. +## Developing with the TypeScript Client + +When developing Admin API features alongside the TypeScript client, you can use [npm link](https://docs.npmjs.com/cli/v11/commands/npm-link/) to work with both repositories locally without publishing to npm. + +### Prerequisites +Clone the BMLT TypeScript client repository: +```bash +git clone https://github.com/bmlt-enabled/bmlt-server-typescript-client.git +``` + +### One-time Setup +1. Link the TypeScript client globally: +```bash +cd /path/to/bmlt-server-typescript-client +npm link +``` + +2. Link the client in the BMLT server frontend: +```bash +cd /path/to/bmlt-server/src +npm link bmlt-server-typescript-client +``` + +### Development Workflow +After making API changes, regenerate the TypeScript client: + +1. Generate updated OpenAPI documentation: +```bash +cd /path/to/bmlt-server +make generate-api-json +``` + +2. Regenerate the TypeScript client: +```bash +cd /path/to/bmlt-server-typescript-client +rm openapi.json +make generate +``` + +This allows you to develop the API without changing your configs or imports. + ## Some useful `make` commands - `make help` Describe all of the make commands. diff --git a/Makefile b/Makefile index eb9ee9ef6..e2d105671 100644 --- a/Makefile +++ b/Makefile @@ -119,7 +119,7 @@ zip: $(ZIP_FILE) ## Builds zip file .PHONY: docker docker: zip ## Builds Docker Image - docker build --pull --build-arg PHP_VERSION=$(BASE_IMAGE_TAG) -f docker/$(DOCKERFILE) . -t $(IMAGE):$(TAG) + docker build --pull --build-arg PHP_VERSION=$(BASE_IMAGE_TAG) --label "org.opencontainers.image.revision=$(COMMIT)" --label "org.opencontainers.image.created=$(shell date -u +'%Y-%m-%dT%H:%M:%SZ')" -f docker/$(DOCKERFILE) . -t $(IMAGE):$(TAG) .PHONY: docker-push docker-push: ## Pushes docker image to Dockerhub @@ -169,7 +169,7 @@ phpstan: ## PHP Larastan Code Analysis .PHONY: docker-publish-base docker-publish-base: ## Builds Base Docker Image - docker buildx build --platform linux/amd64,linux/arm64/v8 -f docker/Dockerfile-base docker/ -t $(BASE_IMAGE):$(BASE_IMAGE_TAG) --push + docker buildx build --platform linux/amd64,linux/arm64/v8 --label "org.opencontainers.image.revision=$(COMMIT)" --label "org.opencontainers.image.created=$(shell date -u +'%Y-%m-%dT%H:%M:%SZ')" -f docker/Dockerfile-base docker/ -t $(BASE_IMAGE):$(BASE_IMAGE_TAG) --push .PHONY: mysql mysql: ## Runs mysql cli in mysql container diff --git a/src/app/Console/Commands/ImportRootServers.php b/src/app/Console/Commands/ImportRootServers.php index 33f7e2297..89319c435 100644 --- a/src/app/Console/Commands/ImportRootServers.php +++ b/src/app/Console/Commands/ImportRootServers.php @@ -84,16 +84,8 @@ private function importRootServersList(RootServerRepositoryInterface $rootServer { try { $url = $this->option('list-url'); - $response = $this->httpGet($url); - $externalRootServers = collect($response) - ->map(function ($rootServer) { - try { - return new ExternalRootServer($rootServer); - } catch (InvalidObjectException) { - return null; - } - }) - ->reject(fn($e) => is_null($e)); + $response = $this->httpGet($url, true); + $externalRootServers = collect($response)->map(fn ($rs) => new ExternalRootServer($rs)); $rootServerRepository->import($externalRootServers); } catch (\Exception $e) { $this->error($e->getMessage()); @@ -261,13 +253,17 @@ private function analyzeTables(): void } } - private function httpGet(string $url): array + private function httpGet(string $url, bool $shouldLogResponse = false): array { sleep(self::$requestDelaySeconds); $headers = ['User-Agent' => 'Mozilla/5.0 (X11; Linux x86_64; rv:52.0) Gecko/20100101 Firefox/52.0 +aggregator']; $response = Http::withHeaders($headers)->retry(3, self::$retryDelaySeconds * 1000)->get($url); + if ($shouldLogResponse) { + $this->info("Response from $url: $response"); + } + if (!$response->ok()) { throw new \Exception("Got bad status code {$response->status()} from $url"); } diff --git a/src/app/Http/Controllers/Admin/MeetingController.php b/src/app/Http/Controllers/Admin/MeetingController.php index abbfaf273..b306b565e 100644 --- a/src/app/Http/Controllers/Admin/MeetingController.php +++ b/src/app/Http/Controllers/Admin/MeetingController.php @@ -55,6 +55,7 @@ public function index(Request $request) weekdaysInclude: $days, servicesInclude: $serviceBodyIds, searchString: $searchString, + returnGroups: true, ); return MeetingResource::collection($meetings); @@ -189,6 +190,13 @@ private function validateInputs(Request $request, $skipVenueTypeLocationValidati 'worldId' => 'nullable|string|max:30', 'name' => 'required|string|max:128', 'timeZone' => ['nullable', 'string', 'max:40', new IANATimeZone], + 'membersOfGroup' => 'sometimes|nullable|array', + 'membersOfGroup.*.id_bigint' => 'nullable|int|exists:comdef_meetings_main,id_bigint', + 'membersOfGroup.*.day' => 'required|int|between:0,6', + 'membersOfGroup.*.startTime' => 'required|date_format:H:i', + 'membersOfGroup.*.duration' => 'required|date_format:H:i', + 'membersOfGroup.*.formatIds' => 'present|array', + 'membersOfGroup.*.formatIds.*' => ['int', 'exists:comdef_formats,shared_id_bigint', Rule::notIn([$this->getVirtualFormatId(), $this->getTemporarilyClosedFormatId(), $this->getHybridFormatId()])], ], $this->getDataFieldValidators($skipVenueTypeLocationValidation)) )); } @@ -272,7 +280,17 @@ private function buildValuesArray(Collection $validated): array 'worldid_mixed' => $validated['worldId'] ?? null, 'meeting_name' => $validated['name'], ]; - + $values['membersOfGroup'] = []; + foreach ($validated['membersOfGroup'] ?? [] as $member) { + $member['venueType'] = $validated['venueType']; + $values['membersOfGroup'][] = [ + 'id_bigint' => $member['id_bigint'] ?? null, + 'weekday_tinyint' => $member['day'], + 'start_time' => \DateTime::createFromFormat('H:i', $member['startTime'])->format('H:i:s'), + 'duration_time' => \DateTime::createFromFormat('H:i', $member['duration'])->format('H:i:s'), + 'formats' => $this->buildFormatsString(collect($member)), + ]; + } $customFields = $this->getCustomFields(); return collect($values) diff --git a/src/app/Http/Controllers/Admin/Swagger/MeetingController.php b/src/app/Http/Controllers/Admin/Swagger/MeetingController.php index 73b7ecd09..903bbd843 100644 --- a/src/app/Http/Controllers/Admin/Swagger/MeetingController.php +++ b/src/app/Http/Controllers/Admin/Swagger/MeetingController.php @@ -43,8 +43,11 @@ * @OA\Property(property="bus_lines", type="string", example="string"), * @OA\Property(property="train_lines", type="string", example="string"), * @OA\Property(property="comments", type="string", example="string"), + * @OA\Property(property="membersOfGroup",type="array", + * @OA\Items(type="object", example={"day": "0", "startTime":"19:00", "duration": "01:30", "formats": "[]"}), + * ), * @OA\Property(property="customFields", type="object", example={"key1": "value1", "key2": "value2"}, - * @OA\AdditionalProperties(type="string") + * @OA\AdditionalProperties(type="string"), * ), * ), * @OA\Schema(schema="Meeting", required={"id", "serviceBodyId", "formatIds", "venueType", "temporarilyVirtual", "day", "startTime", "duration", "timeZone", "latitude", "longitude", "published", "email", "worldId", "name"}, diff --git a/src/app/Http/Controllers/Query/SwitcherController.php b/src/app/Http/Controllers/Query/SwitcherController.php index ca5e89a8f..bb3309f81 100644 --- a/src/app/Http/Controllers/Query/SwitcherController.php +++ b/src/app/Http/Controllers/Query/SwitcherController.php @@ -256,6 +256,12 @@ private function getSearchResults(Request $request, ?string $dataFormat = null): } else { $published = false; } + $returnGroups = $request->input('return_groups', '0'); + if ($returnGroups == '1') { + $returnGroups = true; + } else { + $returnGroups = false; + } $sortKeys = $request->input('sort_keys'); $sortKeys = empty($sortKeys) ? null : explode(',', $sortKeys); @@ -353,6 +359,7 @@ private function getSearchResults(Request $request, ?string $dataFormat = null): sortKeys: $sortKeys, pageSize: $pageSize, pageNum: $pageNum, + returnGroups: $returnGroups, ); // This code to calculate the formats fields is really inefficient, but necessary because diff --git a/src/app/Http/Resources/Admin/MeetingResource.php b/src/app/Http/Resources/Admin/MeetingResource.php index 070289e3c..2ff764f54 100644 --- a/src/app/Http/Resources/Admin/MeetingResource.php +++ b/src/app/Http/Resources/Admin/MeetingResource.php @@ -62,6 +62,24 @@ public function toArray($request) ->reject(fn ($id) => !self::$formatsById->has($id)) ->sort(); + $membersOfGroup = []; + foreach ($this->groupMembers->toResourceCollection(MeetingResource::class) as $member) { + $memberFormatIds = empty($member->formats) ? collect([]) : collect(explode(',', $member->formats)) + ->map(fn ($id) => intval($id)) + ->reject(fn ($id) => !self::$formatsById->has($id)) + ->sort(); + $membersOfGroup[] = [ + 'id_bigint' => $member->id_bigint, + 'day' => $member->weekday_tinyint, + 'startTime' => is_null($member->start_time) ? null : (\DateTime::createFromFormat('H:i:s', $member->start_time) ?: \DateTime::createFromFormat('H:i', $member->start_time))->format('H:i'), + 'duration' => is_null($member->duration_time) ? null : (\DateTime::createFromFormat('H:i:s', $member->duration_time) ?: \DateTime::createFromFormat('H:i', $member->duration_time))->format('H:i'), + 'formats' => $memberFormatIds, + ]; + usort($membersOfGroup, fn($a, $b) => $a['day'] <=> $b['day'] ?: $a['startTime'] <=> $b['startTime']); + }; + if (!empty($membersOfGroup)) { + $membersOfGroup = ['membersOfGroup' => $membersOfGroup]; + } return array_merge( [ 'id' => $this->id_bigint, @@ -79,7 +97,9 @@ public function toArray($request) 'email' => $this->email_contact ?: null, 'worldId' => $this->worldid_mixed ?: null, 'name' => $meetingData->get('meeting_name') ?: null, + 'is_group' => $this->is_group ?? 0, ], + $membersOfGroup, self::$dataTemplates ->reject(fn ($t, $_) => self::$customFields->contains($t->key)) ->mapWithKeys(fn ($t, $_) => [$t->key => $meetingData->get($t->key) ?: null]) diff --git a/src/app/Http/Resources/Query/MeetingResource.php b/src/app/Http/Resources/Query/MeetingResource.php index b7b3e307e..64ca50ae6 100644 --- a/src/app/Http/Resources/Query/MeetingResource.php +++ b/src/app/Http/Resources/Query/MeetingResource.php @@ -96,7 +96,20 @@ public function toArray($request) $meeting[$meetingDataTemplate->key] = $meetingData->get($meetingDataTemplate->key, '') ?? ''; } - + // Could be expensive. Maybe we want to control when this is done.... + if ($this->getIsGroup() && (!self::$hasDataFieldKeys || self::$dataFieldKeys->has('membersOfGroup'))) { + $meeting['membersOfGroup'] = []; + foreach ($this->groupMembers->toResourceCollection(MeetingResource::class) as $member) { + $meeting['membersOfGroup'][] = [ + 'id_bigint' => $member->getIdBigint(), + 'weekday_tinyint' => $member->getWeekdayTinyint(), + 'start_time' => $member->getStartTime(), + 'duration_time' => $member->getDurationTime(), + 'formats' => $member->getFormats(), + ]; + } + usort($meeting['membersOfGroup'], fn($a, $b) => $a['weekday_tinyint'] <=> $b['weekday_tinyint'] ?: $a['start_time'] <=> $b['start_time']); + } return $meeting; } @@ -248,6 +261,13 @@ private function getLangEnum() $this->lang_enum ?? '' ); } + private function getIsGroup() + { + return $this->when( + !self::$hasDataFieldKeys || self::$dataFieldKeys->has('is_group'), + $this->is_group ?? 0 + ); + } private function getLongitude() { return $this->when( diff --git a/src/app/Interfaces/MeetingRepositoryInterface.php b/src/app/Interfaces/MeetingRepositoryInterface.php index ea7992f04..805c3e24e 100644 --- a/src/app/Interfaces/MeetingRepositoryInterface.php +++ b/src/app/Interfaces/MeetingRepositoryInterface.php @@ -41,6 +41,7 @@ public function getSearchResults( array $sortKeys = null, int $pageSize = null, int $pageNum = null, + bool $returnGroups = false, ): Collection; public function getFieldKeys(): Collection; public function getFieldValues(string $fieldName, array $specificFormats = [], bool $allFormats = false): Collection; diff --git a/src/app/Models/Meeting.php b/src/app/Models/Meeting.php index bc8e06324..531ae0d1a 100644 --- a/src/app/Models/Meeting.php +++ b/src/app/Models/Meeting.php @@ -26,6 +26,8 @@ class Meeting extends Model 'latitude', 'published', 'email_contact', + 'is_group', + 'group_id' ]; public static $mainFields = [ @@ -81,7 +83,22 @@ public function longData() { return $this->hasMany(MeetingLongData::class, 'meetingid_bigint'); } - + public function group() + { + return $this->belongsTo(Meeting::class, 'group_id', 'id_bigint'); + } + public function groupMembers() + { + return $this->hasMany(Meeting::class, 'group_id'); + } + public function groupData() + { + return $this->hasMany(MeetingData::class, 'meetingid_bigint', 'group_id'); + } + public function groupLongData() + { + return $this->hasMany(MeetingLongData::class, 'meetingid_bigint', 'group_id'); + } private ?string $calculatedFormatKeys = null; private function setCalculatedFormatKeys(string $formatKeyStrings) { @@ -106,12 +123,16 @@ public function getCalculatedFormatSharedIds(): string public function calculateFormatsFields(Collection $formatsById) { - if (is_null($this->formats) || $this->formats == '') { + $formatIds = []; + if (!is_null($this->formats) && !$this->formats == '') { + $formatIds = explode(',', $this->formats); + } + if (!is_null($this->group) && !is_null($this->group->formats) && !$this->group->formats == '') { + $formatIds = array_merge($formatIds, explode(',', $this->group->formats)); + } + if (count($formatIds) === 0) { return; } - - $formatIds = explode(',', $this->formats); - $calculatedFormats = []; foreach ($formatIds as $formatId) { $format = $formatsById->get(intval($formatId)); diff --git a/src/app/Repositories/MeetingRepository.php b/src/app/Repositories/MeetingRepository.php index ebd58c4f7..78846cdbb 100644 --- a/src/app/Repositories/MeetingRepository.php +++ b/src/app/Repositories/MeetingRepository.php @@ -51,21 +51,31 @@ public function getSearchResults( array $sortKeys = null, int $pageSize = null, int $pageNum = null, + bool $returnGroups = false ): Collection { - $eagerLoadRelations = ['data', 'longdata']; + $eagerLoadRelations = ['data', 'longdata', 'group']; if ($eagerServiceBodies) { $eagerLoadRelations[] = 'serviceBody'; } if ($eagerRootServers) { $eagerLoadRelations[] = 'rootServer'; } + if ($returnGroups) { + $eagerLoadRelations[] = 'groupMembers'; + } $meetings = Meeting::with($eagerLoadRelations); if (!is_null($published)) { $meetings = $meetings->where('published', $published ? 1 : 0); } - + if (!is_null($returnGroups)) { + if ($returnGroups) { + $meetings = $meetings->whereNull('group_id'); + } else { + $meetings = $meetings->where('is_group', 0); + } + } if (!is_null($meetingIdsInclude)) { $meetings = $meetings->whereIn('id_bigint', $meetingIdsInclude); } @@ -114,7 +124,13 @@ public function getSearchResults( ->orWhere('formats', "$formatId") ->orWhere('formats', 'LIKE', "$formatId,%") ->orWhere('formats', 'LIKE', "%,$formatId,%") - ->orWhere('formats', 'LIKE', "%,$formatId"); + ->orWhere('formats', 'LIKE', "%,$formatId") + ->orWhereHas('group', function (Builder $query) use ($formatId) { + $query->where('formats', "$formatId") + ->orWhere('formats', 'LIKE', "$formatId,%") + ->orWhere('formats', 'LIKE', "%,$formatId,%") + ->orWhere('formats', 'LIKE', "%,$formatId"); + }); }); } } else { @@ -125,7 +141,13 @@ public function getSearchResults( ->orWhere('formats', "$formatId") ->orWhere('formats', 'LIKE', "$formatId,%") ->orWhere('formats', 'LIKE', "%,$formatId,%") - ->orWhere('formats', 'LIKE', "%,$formatId"); + ->orWhere('formats', 'LIKE', "%,$formatId") + ->orWhereHas('group', function (Builder $query) use ($formatId) { + $query->where('formats', "$formatId") + ->orWhere('formats', 'LIKE', "$formatId,%") + ->orWhere('formats', 'LIKE', "%,$formatId,%") + ->orWhere('formats', 'LIKE', "%,$formatId"); + }); }); } }); @@ -155,6 +177,9 @@ public function getSearchResults( ->whereHas('data', function (Builder $query) use ($meetingKey, $meetingKeyValue) { $query->where('key', $meetingKey)->where('data_string', $meetingKeyValue); }) + ->orWhereHas('groupData', function (Builder $query) use ($meetingKey, $meetingKeyValue) { + $query->where('key', $meetingKey)->where('data_string', $meetingKeyValue); + }) ->orWhereHas('longdata', function (Builder $query) use ($meetingKey, $meetingKeyValue) { $query->where('key', $meetingKey)->where('data_blob', $meetingKeyValue); }); @@ -624,9 +649,39 @@ public function create(array $values): Meeting $mainValues = $values->reject(fn ($_, $fieldName) => !in_array($fieldName, Meeting::$mainFields))->toArray(); $dataTemplates = $this->getDataTemplates(); $dataValues = $values->reject(fn ($_, $fieldName) => !$dataTemplates->has($fieldName)); - - return DB::transaction(function () use ($mainValues, $dataValues, $dataTemplates) { + $members = $values['membersOfGroup'] ?? []; + $mainValues['is_group'] = (isset($values['membersOfGroup']) && ($values['membersOfGroup'] !== null)) ? 1 : 0; + $mainValues['group_id'] = null; + if ($mainValues['is_group'] == 1) { + $day = 7; + $time = '24:00'; + foreach ($values['membersOfGroup'] as $member) { + if ($member['weekday_tinyint'] < $day) { + $day = $member['weekday_tinyint']; + $time = $member['start_time']; + } elseif ($member['weekday_tinyint'] == $day && $member['start_time'] < $time) { + $time = $member['start_time']; + } + } + if ($day <= 6) { + $mainValues['weekday_tinyint'] = $day; + $mainValues['start_time'] = $time; + } + } + return DB::transaction(function () use ($mainValues, $dataValues, $dataTemplates, $members) { $meeting = Meeting::create($mainValues); + if ($mainValues['is_group'] == 1) { + $meeting['membersOfGroup'] = []; + foreach ($members as $member) { + $mainValues['start_time'] = $member['start_time']; + $mainValues['weekday_tinyint'] = $member['weekday_tinyint']; + $mainValues['duration_time'] = $member['duration_time']; + $mainValues['formats'] = $member['formats']; + $mainValues['is_group'] = 0; + $mainValues['group_id'] = $meeting->id_bigint; + Meeting::create($mainValues); + } + } foreach ($dataValues as $fieldName => $fieldValue) { $t = $dataTemplates->get($fieldName); if (strlen($fieldValue) > 255) { @@ -660,14 +715,46 @@ public function update(int $id, array $values): bool { $values = collect($values); $mainValues = $values->reject(fn ($_, $fieldName) => !in_array($fieldName, Meeting::$mainFields))->toArray(); + $mainValues['is_group'] = (isset($values['membersOfGroup']) && ($values['membersOfGroup'] !== null)) ? 1 : 0; + $mainValues['group_id'] = null; + if ($mainValues['is_group'] == 1) { + $day = 7; + $time = '24:00'; + foreach ($values['membersOfGroup'] as $member) { + if ($member['weekday_tinyint'] < $day) { + $day = $member['weekday_tinyint']; + $time = $member['start_time']; + } elseif ($member['weekday_tinyint'] == $day && $member['start_time'] < $time) { + $time = $member['start_time']; + } + } + if ($day <= 6) { + $mainValues['weekday_tinyint'] = $day; + $mainValues['start_time'] = $time; + } + } $dataTemplates = $this->getDataTemplates(); $dataValues = $values->reject(fn ($_, $fieldName) => !$dataTemplates->has($fieldName)); - - return DB::transaction(function () use ($id, $mainValues, $dataValues, $dataTemplates) { + $members = $values['membersOfGroup'] ?? []; + return DB::transaction(function () use ($id, $mainValues, $dataValues, $dataTemplates, $members) { $meeting = Meeting::find($id); $meeting->loadMissing(['data', 'longdata']); if (!is_null($meeting)) { Meeting::query()->where('id_bigint', $id)->update($mainValues); + Meeting::query()->where('group_id', $id)->whereNotIn('id_bigint', array_column($members, 'id_bigint'))->delete(); + foreach ($members as $member) { + $mainValues['start_time'] = $member['start_time']; + $mainValues['weekday_tinyint'] = $member['weekday_tinyint']; + $mainValues['duration_time'] = $member['duration_time']; + $mainValues['formats'] = $member['formats']; + $mainValues['is_group'] = 0; + $mainValues['group_id'] = $id; + if (!empty($member['id_bigint'])) { + Meeting::query()->where('id_bigint', $member['id_bigint'])->update($mainValues); + } else { + Meeting::create($mainValues); + } + } MeetingData::query()->where('meetingid_bigint', $id)->delete(); MeetingLongData::query()->where('meetingid_bigint', $id)->delete(); foreach ($dataValues as $fieldName => $fieldValue) { @@ -709,6 +796,7 @@ public function delete(int $id): bool $meeting->loadMissing(['data', 'longdata']); MeetingData::query()->where('meetingid_bigint', $meeting->id_bigint)->delete(); MeetingLongData::query()->where('meetingid_bigint', $meeting->id_bigint)->delete(); + Meeting::query()->where('group_id', $meeting->id_bigint)->delete(); Meeting::query()->where('id_bigint', $meeting->id_bigint)->delete(); if (!legacy_config('aggregator_mode_enabled')) { $this->saveChange($meeting, null); @@ -858,10 +946,10 @@ public function import(int $rootServerId, Collection $externalObjects): void if (is_null($db)) { $values = $this->externalMeetingToValuesArray($rootServerId, $serviceBodyId, $external, $formatSourceIdToSharedIdMap); - $this->create($values); + $this->create($values, 'en'); } else if (!$external->isEqual($db, $serviceBodyIdToSourceIdMap, $formatSharedIdToSourceIdMap)) { $values = $this->externalMeetingToValuesArray($rootServerId, $serviceBodyId, $external, $formatSourceIdToSharedIdMap); - $this->update($db->id_bigint, $values); + $this->update($db->id_bigint, $values, 'en'); } } } diff --git a/src/database/migrations/2025_10_19_131156_add_group_to_meetings.php b/src/database/migrations/2025_10_19_131156_add_group_to_meetings.php new file mode 100644 index 000000000..cc4f210b8 --- /dev/null +++ b/src/database/migrations/2025_10_19_131156_add_group_to_meetings.php @@ -0,0 +1,30 @@ +boolean('is_group')->default(0); + $table->integer('group_id')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('comdef_meetings_main', function (Blueprint $table) { + $table->dropColumn('is_group'); + $table->dropColumn('group_id'); + }); + } +}; diff --git a/src/package-lock.json b/src/package-lock.json index 4acd7d7c3..e62209788 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -55,7 +55,7 @@ "tslib": "^2.8.1", "typescript": "^5.8.3", "typescript-eslint": "^8.31.1", - "vite": "^7.1.9", + "vite": "^7.1.11", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.1.2" } @@ -9761,9 +9761,9 @@ } }, "node_modules/vite": { - "version": "7.1.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", - "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", + "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/package.json b/src/package.json index c2ef5d55e..e7e846751 100644 --- a/src/package.json +++ b/src/package.json @@ -48,7 +48,7 @@ "tslib": "^2.8.1", "typescript": "^5.8.3", "typescript-eslint": "^8.31.1", - "vite": "^7.1.9", + "vite": "^7.1.11", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.1.2" }, diff --git a/src/resources/js/components/MeetingEditForm.svelte b/src/resources/js/components/MeetingEditForm.svelte index 046eaa0e1..e11430a73 100644 --- a/src/resources/js/components/MeetingEditForm.svelte +++ b/src/resources/js/components/MeetingEditForm.svelte @@ -26,7 +26,7 @@ import type { Format, Meeting, MeetingPartialUpdate, ServiceBody } from 'bmlt-server-client'; import { translations } from '../stores/localization'; import MeetingDeleteModal from './MeetingDeleteModal.svelte'; - import { TrashBinOutline } from 'flowbite-svelte-icons'; + import { TrashBinOutline, PlusOutline } from 'flowbite-svelte-icons'; interface Props { selectedMeeting: Meeting | null; @@ -40,10 +40,20 @@ let { selectedMeeting, serviceBodies, formats, onSaved, onDeleted }: Props = $props(); const daysOfWeek: string[] = [$translations.day0, $translations.day1, $translations.day2, $translations.day3, $translations.day4, $translations.day5, $translations.day6]; - - const tabs = selectedMeeting - ? [$translations.tabsBasic, $translations.tabsLocation, $translations.tabsOther, $translations.tabsChanges] - : [$translations.tabsBasic, $translations.tabsLocation, $translations.tabsOther]; + let tabs = $state( + selectedMeeting + ? selectedMeeting.membersOfGroup && selectedMeeting.membersOfGroup.length > 0 + ? [$translations.tabsBasic, $translations.tabsLocation, $translations.tabsMeetingTimes, $translations.tabsOther, $translations.tabsChanges] + : [$translations.tabsBasic, $translations.tabsLocation, $translations.tabsOther, $translations.tabsChanges] + : [$translations.tabsBasic, $translations.tabsLocation, $translations.tabsOther] + ); + let tabsSnippets = $state( + selectedMeeting + ? selectedMeeting.membersOfGroup && selectedMeeting.membersOfGroup.length > 0 + ? [basicTabContent, locationTabContent, meetingTimesTabContent, otherTabContent, changesTabContent] + : [basicTabContent, locationTabContent, otherTabContent, changesTabContent] + : [basicTabContent, locationTabContent, otherTabContent] + ); const globalSettings = settings; const seenNames = new SvelteSet(); const ignoredFormats = ['VM', 'HY', 'TC']; @@ -112,6 +122,17 @@ const [hours, minutes] = globalSettings.defaultDuration.split(':').map((part) => part.padStart(2, '0')); defaultDuration = hours + ':' + minutes; } + type GroupMember = { id_bigint?: number; day: number; startTime: string; duration: string; formatIds: [] }; + let groupMembers: GroupMember[] = []; + if (selectedMeeting?.membersOfGroup && selectedMeeting.membersOfGroup.length > 0) { + groupMembers = (selectedMeeting.membersOfGroup as Array<{ id_bigint?: number; day?: number; startTime?: string; duration?: string; formats?: [] }>).map((member) => ({ + id_bigint: member.id_bigint ?? undefined, + day: member.day ?? 0, + startTime: member.startTime ?? '12:00', + duration: member.duration ?? defaultDuration, + formatIds: member.formats ?? [] + })); + } const initialValues = { serviceBodyId: selectedMeeting?.serviceBodyId ?? -1, formatIds: selectedMeeting?.formatIds ?? [], @@ -154,12 +175,14 @@ ...Object.fromEntries(globalSettings.customFields.map((field) => [field.name, ''])), ...Object.fromEntries(Object.entries(selectedMeeting.customFields).map(([key, value]) => [key, value ?? ''])) } - : Object.fromEntries(globalSettings.customFields.map((field) => [field.name, ''])) + : Object.fromEntries(globalSettings.customFields.map((field) => [field.name, ''])), + membersOfGroup: groupMembers }; let latitude = $state(initialValues.latitude); let longitude = $state(initialValues.longitude); let manualDrag = false; let formatIdsSelected = $state(initialValues.formatIds); + let membersOfGroup = $state(initialValues.membersOfGroup); let savedMeeting: Meeting; let changes: MeetingChangeResource[] = $state([]); let changesLoaded = $state(false); @@ -249,6 +272,15 @@ } } + // These values will be ignored by the server, but set them to safe values to avoid validation errors + // I'd much prefer to delete these, as that is what the server expects, but I'd have to make the + // properties optional, and this seems like the more maintainable approach for now. + if (values.membersOfGroup && values.membersOfGroup.length > 0) { + values.day = 0; + values.startTime = '00:00'; + values.duration = '01:00'; + } + if (selectedMeeting && saveAsCopy) { const copyData = { ...values, @@ -319,15 +351,34 @@ formatIds: yup.array().of(yup.number()), venueType: yup.number().oneOf(VALID_VENUE_TYPES).required(), temporarilyVirtual: yup.bool(), - day: yup.number().integer().min(0).max(6).required(), + day: yup + .number() + .default(-1) + .when('membersOfGroup', { + is: (membersOfGroup: GroupMember[]) => !membersOfGroup || membersOfGroup.length === 0, + then: (schema) => schema.integer().min(0).max(6).required($translations.dayErrorMessage), + otherwise: (schema) => schema.notRequired() + }), startTime: yup .string() - .matches(/^([0-1]\d|2[0-3]):([0-5]\d)$/) - .required(), // HH:mm + .default('') + .when('membersOfGroup', { + is: (membersOfGroup: GroupMember[]) => !membersOfGroup || membersOfGroup.length === 0, + then: (schema) => + schema + .matches(/^([0-1]\d|2[0-3]):([0-5]\d)$/) // HH:mm + .required($translations.startTimeErrorMessage), + otherwise: (schema) => schema.notRequired() + }), duration: yup .string() - .matches(/^([0-1]\d|2[0-3]):([0-5]\d)$/) - .required(), // HH:mm + .default('') + .matches(/^([0-1]\d|2[0-3]):([0-5]\d)$/) // HH:mm + .when('membersOfGroup', { + is: (membersOfGroup: GroupMember[]) => !membersOfGroup || membersOfGroup.length === 0, + then: (schema) => schema.required($translations.startTimeErrorMessage), + otherwise: (schema) => schema.notRequired() + }), timeZone: yup .string() .oneOf([...timeZones, ''], $translations.timeZoneInvalid) @@ -767,7 +818,33 @@ }); $effect(() => { setData('formatIds', formatIdsSelected); + setData('membersOfGroup', membersOfGroup); }); + function handleDeleteMember(i: number, setData: (d: any, v: any) => void) { + if (membersOfGroup.length <= 1) return; + membersOfGroup.splice(i, 1); + setData('membersOfGroup', membersOfGroup); + } + function handleAdd() { + membersOfGroup.push({ day: 0, startTime: '12:00', duration: '01:30', formatIds: [] }); + setData('membersOfGroup', membersOfGroup); + } + function convertToGroup() { + tabs.splice(2, 0, $translations.tabsMeetingTimes); + tabsSnippets.splice(2, 0, meetingTimesTabContent); + membersOfGroup = [{ day: $data?.day, startTime: $data?.startTime, duration: $data?.duration, formatIds: [] }]; + setData('membersOfGroup', membersOfGroup); + } + function getDuration(i: number): string { + return membersOfGroup[i].duration ?? '01:00'; + } + function setDuration(i: number, d: string, setData: (d: any, v: any) => void) { + membersOfGroup[i].duration = d; + setData('membersOfGroup.' + i + '.duration', d); + } + function isGroup(): boolean { + return tabs.includes($translations.tabsMeetingTimes); + } @@ -821,6 +898,9 @@ Meeting ID: {selectedMeeting.id} + {#if !isGroup()} + + {/if} {/if} + {#if !selectedMeeting && !isGroup()} + + {/if}
@@ -863,35 +946,37 @@ {/if}
-
-
- - - {#if $errors.startTime} - - {$errors.startTime} - - {/if} -
-
- {$translations.durationTitle} - setData('duration', d)} /> - {#if $errors.duration} - - {$errors.duration} - - {/if} + {#if !isGroup()} +
+
+ + + {#if $errors.startTime} + + {$errors.startTime} + + {/if} +
+
+ {$translations.durationTitle} + setData('duration', d)} /> + {#if $errors.duration} + + {$errors.duration} + + {/if} +
-
+ {/if}
@@ -942,7 +1027,6 @@ {/if}
{/snippet} - {#snippet locationTabContent()}
@@ -1152,7 +1236,61 @@
{/snippet} - +{#snippet meetingTimesTabContent()} +
+ +
+ {#each membersOfGroup as _, i} + {#if membersOfGroup[i]?.id_bigint} + + {/if} +
+
+
+
+
+ + +
+
+ {$translations.durationTitle} + setDuration(i, d, setData)} /> +
+
+
+
+ +
+
+
+ + + {#snippet children({ item, clear })} +
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="presentation"> + + {item.name} + +
+ {/snippet} +
+
+
+ {/each} +{/snippet} {#snippet otherTabContent()}
@@ -1286,7 +1424,7 @@ {/snippet}
- +
diff --git a/src/resources/js/lang/de.ts b/src/resources/js/lang/de.ts index 8dd73b553..7d4305cc6 100644 --- a/src/resources/js/lang/de.ts +++ b/src/resources/js/lang/de.ts @@ -209,6 +209,7 @@ export const deTranslations = { tabsBasic: 'Basic', tabsChanges: 'Änderungen', tabsLocation: 'Standort', + tabsMeetingTimes: 'Meeting Zeiten', tabsOther: 'Andere', technicalDetails: 'Technische Details', time: 'Zeit', diff --git a/src/resources/js/lang/en.ts b/src/resources/js/lang/en.ts index 8780ad9a6..6ca0db845 100644 --- a/src/resources/js/lang/en.ts +++ b/src/resources/js/lang/en.ts @@ -263,6 +263,7 @@ export const enTranslations = { tabsBasic: 'Basic', tabsChanges: 'Changes', tabsLocation: 'Location', + tabsMeetingTimes: 'Meeting Times', tabsOther: 'Other', technicalDetails: 'Technical Details', time: 'Time', diff --git a/src/storage/api-docs/api-docs.json b/src/storage/api-docs/api-docs.json index 3a3342cf7..136865ae6 100644 --- a/src/storage/api-docs/api-docs.json +++ b/src/storage/api-docs/api-docs.json @@ -2489,6 +2489,18 @@ "type": "string", "example": "string" }, + "membersOfGroup": { + "type": "array", + "items": { + "type": "object", + "example": { + "day": "0", + "startTime": "19:00", + "duration": "01:30", + "formats": "[]" + } + } + }, "customFields": { "type": "object", "example": {