diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/InitialDataController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/InitialDataController.kt index 74aa31efb9..7922c402d1 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/InitialDataController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/InitialDataController.kt @@ -6,6 +6,7 @@ import io.tolgee.api.EeSubscriptionProvider import io.tolgee.component.PreferredOrganizationFacade import io.tolgee.hateoas.InitialDataModel import io.tolgee.hateoas.ee.IEeSubscriptionModelAssembler +import io.tolgee.notifications.UserNotificationService import io.tolgee.openApiDocs.OpenApiHideFromPublicDocs import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.security.UserPreferencesService @@ -32,6 +33,7 @@ class InitialDataController( private val eeSubscriptionModelAssembler: IEeSubscriptionModelAssembler, private val eeSubscriptionProvider: EeSubscriptionProvider, private val announcementController: AnnouncementController, + private val userNotificationService: UserNotificationService, ) : IController { @GetMapping(value = [""]) @Operation(summary = "Get initial data", description = "Returns initial data required by the UI to load") @@ -53,6 +55,7 @@ class InitialDataController( data.preferredOrganization = preferredOrganizationFacade.getPreferred() data.languageTag = userPreferencesService.find(userAccount.id)?.language data.announcement = announcementController.getLatest() + data.unreadNotifications = userNotificationService.getUnreadNotificationsCount(userAccount.id) } return data diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectActivityController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectActivityController.kt index 8572088fc8..01e2ea196f 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectActivityController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectActivityController.kt @@ -9,7 +9,12 @@ import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.media.ExampleObject import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.activity.ActivityService +import io.tolgee.activity.groups.ActivityGroupService +import io.tolgee.dtos.queryResults.ActivityGroupView +import io.tolgee.dtos.request.ActivityGroupFilters import io.tolgee.exceptions.NotFoundException +import io.tolgee.hateoas.activity.ActivityGroupModel +import io.tolgee.hateoas.activity.ActivityGroupModelAssembler import io.tolgee.hateoas.activity.ModifiedEntityModel import io.tolgee.hateoas.activity.ModifiedEntityModelAssembler import io.tolgee.hateoas.activity.ProjectActivityModel @@ -31,6 +36,7 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping @Suppress("MVCPathVariableInspection", "SpringJavaInjectionPointsAutowiringInspection") @RestController @@ -44,6 +50,10 @@ class ProjectActivityController( private val modificationResourcesAssembler: PagedResourcesAssembler, private val projectActivityModelAssembler: ProjectActivityModelAssembler, private val modifiedEntityModelAssembler: ModifiedEntityModelAssembler, + private val activityGroupService: ActivityGroupService, + private val groupPagedResourcesAssembler: PagedResourcesAssembler, + private val groupModelAssembler: ActivityGroupModelAssembler, + private val requestMappingHandlerMapping: RequestMappingHandlerMapping, ) { @Operation(summary = "Get project activity") @GetMapping("", produces = [MediaTypes.HAL_JSON_VALUE]) @@ -52,7 +62,7 @@ class ProjectActivityController( fun getActivity( @ParameterObject pageable: Pageable, ): PagedModel { - val views = activityService.findProjectActivity(projectId = projectHolder.project.id, pageable) + val views = activityService.getProjectActivity(projectId = projectHolder.project.id, pageable) return activityPagedResourcesAssembler.toModel(views, projectActivityModelAssembler) } @@ -64,7 +74,7 @@ class ProjectActivityController( @PathVariable revisionId: Long, ): ProjectActivityModel { val views = - activityService.findProjectActivity(projectId = projectHolder.project.id, revisionId) + activityService.getProjectActivity(projectId = projectHolder.project.id, revisionId) ?: throw NotFoundException() return projectActivityModelAssembler.toModel(views) } @@ -91,4 +101,24 @@ class ProjectActivityController( ) return modificationResourcesAssembler.toModel(page, modifiedEntityModelAssembler) } + + @Operation( + summary = "Get project activity groups", + description = "This endpoints returns the activity grouped by time windows so it's easier to read on the frontend.", + ) + @GetMapping("/groups", produces = [MediaTypes.HAL_JSON_VALUE]) + @RequiresProjectPermissions([Scope.ACTIVITY_VIEW]) + @AllowApiAccess + fun getActivityGroups( + @ParameterObject pageable: Pageable, + @ParameterObject activityGroupFilters: ActivityGroupFilters, + ): PagedModel { + val views = + activityGroupService.getProjectActivityGroups( + projectId = projectHolder.project.id, + pageable, + activityGroupFilters, + ) + return groupPagedResourcesAssembler.toModel(views, groupModelAssembler) + } } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectActivityGroupItemsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectActivityGroupItemsController.kt new file mode 100644 index 0000000000..cad75a1e84 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectActivityGroupItemsController.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2020. Tolgee + */ + +package io.tolgee.api.v2.controllers + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.activity.groups.viewProviders.createKey.CreateKeyGroupItemModel +import io.tolgee.activity.groups.viewProviders.createKey.CreateKeyGroupModelProvider +import io.tolgee.model.enums.Scope +import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authorization.RequiresProjectPermissions +import org.springdoc.core.annotations.ParameterObject +import org.springframework.data.domain.Pageable +import org.springframework.hateoas.MediaTypes +import org.springframework.hateoas.PagedModel +import org.springframework.hateoas.PagedModel.PageMetadata +import org.springframework.web.bind.annotation.CrossOrigin +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Suppress("MVCPathVariableInspection") +@RestController +@CrossOrigin(origins = ["*"]) +@RequestMapping(value = ["/v2/projects/{projectId}/activity/group-items", "/v2/projects/activity/group-items"]) +@Tag(name = "Activity Groups") +class ProjectActivityGroupItemsController( + private val createKeyGroupModelProvider: CreateKeyGroupModelProvider, +) { + @Operation( + summary = "Get CREATE_KEY group items", + ) + @GetMapping("/create-key/{groupId}", produces = [MediaTypes.HAL_JSON_VALUE]) + @RequiresProjectPermissions([Scope.ACTIVITY_VIEW]) + @AllowApiAccess + fun getCreateKeyItems( + @ParameterObject pageable: Pageable, + @PathVariable groupId: Long, + ): PagedModel { + val data = createKeyGroupModelProvider.provideItems(groupId, pageable) + return PagedModel.of( + data.content, + PageMetadata(data.pageable.pageSize.toLong(), data.pageable.pageNumber.toLong(), data.totalElements), + ) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/notifications/NotificationPreferencesController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/notifications/NotificationPreferencesController.kt new file mode 100644 index 0000000000..53c75fe6ff --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/notifications/NotificationPreferencesController.kt @@ -0,0 +1,128 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.api.v2.controllers.notifications + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.notifications.NotificationPreferencesService +import io.tolgee.notifications.dto.NotificationPreferencesDto +import io.tolgee.security.authentication.AuthenticationFacade +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(value = ["/v2/notifications/preferences"]) +@Tag(name = "Notification preferences") +class NotificationPreferencesController( + private val authenticationFacade: AuthenticationFacade, + private val notificationPreferencesService: NotificationPreferencesService, +) { + @GetMapping("") + @Operation(summary = "Fetch the global preferences and all overrides of the current user") + fun getAllPreferences(): Map { + return notificationPreferencesService.getAllPreferences(authenticationFacade.authenticatedUser.id) + } + + @GetMapping("/global") + @Operation(summary = "Fetch the global preferences for the current user") + fun getGlobalPreferences(): NotificationPreferencesDto { + return notificationPreferencesService.getGlobalPreferences(authenticationFacade.authenticatedUser.id) + } + + @PutMapping("/global") + @Operation(summary = "Update the global notification preferences of the current user") + fun updateGlobalPreferences( + @RequestBody @Validated preferencesDto: NotificationPreferencesDto, + ): NotificationPreferencesDto { + val updated = + notificationPreferencesService.setPreferencesOfUser( + authenticationFacade.authenticatedUser.id, + preferencesDto, + ) + + return NotificationPreferencesDto.fromEntity(updated) + } + + @GetMapping("/project/{id}") + @Operation(summary = "Fetch the notification preferences of the current user for a specific project") + fun getPerProjectPreferences( + @PathVariable("id") id: Long, + ): NotificationPreferencesDto { + return notificationPreferencesService.getProjectPreferences( + authenticationFacade.authenticatedUser.id, + id, + ) + } + + @PutMapping("/project/{id}") + @Operation(summary = "Update the notification preferences of the current user for a specific project") + fun updatePerProjectPreferences( + @PathVariable("id") id: Long, + @RequestBody @Validated preferencesDto: NotificationPreferencesDto, + ): NotificationPreferencesDto { + val updated = + notificationPreferencesService.setProjectPreferencesOfUser( + authenticationFacade.authenticatedUser.id, + id, + preferencesDto, + ) + + return NotificationPreferencesDto.fromEntity(updated) + } + + @DeleteMapping("/project/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Delete the notification preferences of the current user for a specific project") + fun deletePerProjectPreferences( + @PathVariable("id") id: Long, + ) { + notificationPreferencesService.deleteProjectPreferencesOfUser( + authenticationFacade.authenticatedUser.id, + id, + ) + } + + @PostMapping("/project/{id}/subscribe") + @Operation(summary = "Subscribe to notifications for a given project") + fun subscribeToProject( + @PathVariable("id") id: Long, + ): ResponseEntity { + return ResponseEntity( + "Coming soon! Please see https://github.com/tolgee/tolgee-platform/issues/1360 for progress on this. :D", + HttpHeaders().also { + @Suppress("UastIncorrectHttpHeaderInspection") + it.add( + "x-hey-curious-reader", + "oh hey there, didn't expect you here... " + + "if you're here, might as well join us! https://tolgee.io/career", + ) + }, + HttpStatus.NOT_IMPLEMENTED, + ) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/notifications/NotificationsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/notifications/NotificationsController.kt new file mode 100644 index 0000000000..426f651835 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/notifications/NotificationsController.kt @@ -0,0 +1,122 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.api.v2.controllers.notifications + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.hateoas.notifications.UserNotificationModel +import io.tolgee.hateoas.notifications.UserNotificationModelAssembler +import io.tolgee.notifications.NotificationStatus +import io.tolgee.notifications.UserNotificationService +import io.tolgee.security.authentication.AuthenticationFacade +import org.springdoc.core.annotations.ParameterObject +import org.springframework.data.domain.Pageable +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(value = ["/v2/notifications"]) +@Tag(name = "Notifications") +class NotificationsController( + private val authenticationFacade: AuthenticationFacade, + private val userNotificationService: UserNotificationService, + private val userNotificationModelAssembler: UserNotificationModelAssembler, +) { + @GetMapping("") + @Operation(summary = "Fetch the current user's notifications") + fun getNotifications( + @RequestParam("status", defaultValue = "UNREAD,READ") status: Set, + @ParameterObject pageable: Pageable, + ): List { + val notifications = + userNotificationService.findNotificationsOfUserFilteredPaged( + authenticationFacade.authenticatedUser.id, + status, + pageable, + ) + + return notifications.map { userNotificationModelAssembler.toModel(it) } + } + + @PostMapping("/mark-as-read") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Marks a given set of notifications as read.") + fun markNotificationsAsRead( + @RequestBody notifications: List, + ) { + userNotificationService.markAsRead( + authenticationFacade.authenticatedUser.id, + notifications, + ) + } + + @PostMapping("/mark-as-read/all") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Marks all notifications as read.") + fun markAllNotificationsAsRead() { + userNotificationService.markAllAsRead(authenticationFacade.authenticatedUser.id) + } + + @PostMapping("/mark-as-unread") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Marks a given set of notifications as unread.") + fun markNotificationsAsUnread( + @RequestBody notifications: List, + ) { + userNotificationService.markAsUnread( + authenticationFacade.authenticatedUser.id, + notifications, + ) + } + + @PostMapping("/mark-as-done") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Marks a given set of notifications as done.") + fun markNotificationsAsDone( + @RequestBody notifications: List, + ) { + userNotificationService.markAsDone( + authenticationFacade.authenticatedUser.id, + notifications, + ) + } + + @PostMapping("/mark-as-done/all") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Marks all notifications as done.") + fun markAllNotificationsAsDone() { + userNotificationService.markAllAsDone(authenticationFacade.authenticatedUser.id) + } + + @PostMapping("/unmark-as-done") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Un-marks a given set of notifications as done.") + fun unmarkNotificationsAsDone( + @RequestBody notifications: Collection, + ) { + userNotificationService.unmarkAsDone( + authenticationFacade.authenticatedUser.id, + notifications, + ) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/InitialDataModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/InitialDataModel.kt index 42af2c8457..84db86ef53 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/InitialDataModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/InitialDataModel.kt @@ -6,11 +6,13 @@ import io.tolgee.hateoas.ee.eeSubscription.EeSubscriptionModel import io.tolgee.hateoas.organization.PrivateOrganizationModel import io.tolgee.hateoas.userAccount.PrivateUserAccountModel -class InitialDataModel( +@Suppress("unused") +data class InitialDataModel( val serverConfiguration: PublicConfigurationDTO, var userInfo: PrivateUserAccountModel? = null, var preferredOrganization: PrivateOrganizationModel? = null, var languageTag: String? = null, val eeSubscription: EeSubscriptionModel? = null, var announcement: AnnouncementDto? = null, + var unreadNotifications: Int? = null, ) diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/activity/ActivityGroupModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/activity/ActivityGroupModel.kt new file mode 100644 index 0000000000..e509ec623a --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/activity/ActivityGroupModel.kt @@ -0,0 +1,18 @@ +package io.tolgee.hateoas.activity + +import io.tolgee.activity.groups.ActivityGroupType +import io.tolgee.hateoas.userAccount.SimpleUserAccountModel +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation +import java.io.Serializable + +@Suppress("unused") +@Relation(collectionRelation = "groups", itemRelation = "group") +class ActivityGroupModel( + val id: Long, + val timestamp: Long, + val type: ActivityGroupType, + val author: SimpleUserAccountModel?, + val data: Any?, + var mentionedLanguageIds: List, +) : RepresentationModel(), Serializable diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/activity/ActivityGroupModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/activity/ActivityGroupModelAssembler.kt new file mode 100644 index 0000000000..d5da300c7b --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/activity/ActivityGroupModelAssembler.kt @@ -0,0 +1,25 @@ +package io.tolgee.hateoas.activity + +import io.tolgee.dtos.queryResults.ActivityGroupView +import io.tolgee.hateoas.userAccount.SimpleUserAccountModelAssembler +import org.springframework.hateoas.server.RepresentationModelAssembler +import org.springframework.stereotype.Component + +@Component +class ActivityGroupModelAssembler( + private val simpleUserAccountModelAssembler: SimpleUserAccountModelAssembler, +) : RepresentationModelAssembler { + override fun toModel(view: ActivityGroupView): ActivityGroupModel { + return ActivityGroupModel( + id = view.id, + timestamp = view.timestamp.time, + type = view.type, + author = + simpleUserAccountModelAssembler.toModel( + view.author, + ), + data = view.data, + mentionedLanguageIds = view.mentionedLanguageIds, + ) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyModel.kt index b2ec32d22f..e8774960b4 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyModel.kt @@ -1,6 +1,6 @@ package io.tolgee.hateoas.key -import io.swagger.v3.oas.annotations.media.Schema +import io.tolgee.api.IKeyModel import org.springframework.hateoas.RepresentationModel import org.springframework.hateoas.server.core.Relation import java.io.Serializable @@ -8,17 +8,9 @@ import java.io.Serializable @Suppress("unused") @Relation(collectionRelation = "keys", itemRelation = "key") open class KeyModel( - @Schema(description = "Id of key record") - val id: Long, - @Schema(description = "Name of key", example = "this_is_super_key") - val name: String, - @Schema(description = "Namespace of key", example = "homepage") - val namespace: String?, - @Schema( - description = "Description of key", - example = "This key is used on homepage. It's a label of sign up button.", - ) - val description: String?, - @Schema(description = "Custom values of the key") - val custom: Map?, -) : RepresentationModel(), Serializable + override val id: Long, + override val name: String, + override val namespace: String?, + override val description: String?, + override val custom: Map?, +) : RepresentationModel(), Serializable, IKeyModel diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithDataModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithDataModel.kt index b23671cfbc..875b95e099 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithDataModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithDataModel.kt @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.tolgee.api.v2.hateoas.invitation.TagModel import io.tolgee.hateoas.screenshot.ScreenshotModel import io.tolgee.hateoas.translations.TranslationModel +import io.tolgee.sharedDocs.Key import org.springframework.hateoas.RepresentationModel import org.springframework.hateoas.server.core.Relation import java.io.Serializable @@ -31,9 +32,9 @@ open class KeyWithDataModel( val tags: Set, @Schema(description = "Screenshots of the key") val screenshots: List, - @Schema(description = "If key is pluralized. If it will be reflected in the editor") + @Schema(description = Key.IS_PLURAL_FIELD) val isPlural: Boolean, - @Schema(description = "The argument name for the plural") + @Schema(description = Key.PLURAL_ARG_NAME_FIELD) val pluralArgName: String?, @Schema(description = "Custom values of the key") val custom: Map, diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/language/LanguageModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/language/LanguageModel.kt index cb7b7ebe4c..50aa7b38ba 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/language/LanguageModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/language/LanguageModel.kt @@ -1,21 +1,22 @@ package io.tolgee.hateoas.language import io.swagger.v3.oas.annotations.media.Schema +import io.tolgee.api.ILanguageModel import org.springframework.hateoas.RepresentationModel import org.springframework.hateoas.server.core.Relation @Suppress("unused") @Relation(collectionRelation = "languages", itemRelation = "language") open class LanguageModel( - val id: Long, + override val id: Long, @Schema(example = "Czech", description = "Language name in english") - val name: String, + override val name: String, @Schema(example = "cs-CZ", description = "Language tag according to BCP 47 definition") - var tag: String, + override var tag: String, @Schema(example = "čeština", description = "Language name in this language") - var originalName: String? = null, + override var originalName: String?, @Schema(example = "\uD83C\uDDE8\uD83C\uDDFF", description = "Language flag emoji as UTF-8 emoji") - var flagEmoji: String? = null, + override var flagEmoji: String?, @Schema(example = "false", description = "Whether is base language of project") var base: Boolean, -) : RepresentationModel() +) : RepresentationModel(), ILanguageModel diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/notifications/UserNotificationModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/notifications/UserNotificationModel.kt new file mode 100644 index 0000000000..141050165e --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/notifications/UserNotificationModel.kt @@ -0,0 +1,37 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.hateoas.notifications + +import io.tolgee.hateoas.batch.BatchJobModel +import io.tolgee.hateoas.project.SimpleProjectModel +import io.tolgee.model.views.activity.SimpleModifiedEntityView +import io.tolgee.notifications.NotificationType +import org.springframework.hateoas.RepresentationModel +import java.io.Serializable +import java.util.* + +@Suppress("unused") +class UserNotificationModel( + val id: Long, + val type: NotificationType, + val project: SimpleProjectModel?, + val batchJob: BatchJobModel?, + val modifiedEntities: List?, + val unread: Boolean, + val markedDoneAt: Date?, + val lastUpdated: Date, +) : RepresentationModel(), Serializable diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/notifications/UserNotificationModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/notifications/UserNotificationModelAssembler.kt new file mode 100644 index 0000000000..1502f11f10 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/notifications/UserNotificationModelAssembler.kt @@ -0,0 +1,71 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.hateoas.notifications + +import io.tolgee.activity.views.ModifiedEntitiesViewProvider +import io.tolgee.api.v2.controllers.notifications.NotificationsController +import io.tolgee.batch.BatchJobService +import io.tolgee.hateoas.batch.BatchJobModelAssembler +import io.tolgee.hateoas.project.SimpleProjectModelAssembler +import io.tolgee.model.activity.ActivityModifiedEntity +import io.tolgee.model.notifications.UserNotification +import io.tolgee.model.views.activity.SimpleModifiedEntityView +import org.springframework.context.ApplicationContext +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class UserNotificationModelAssembler( + private val batchJobService: BatchJobService, + private val simpleProjectModelAssembler: SimpleProjectModelAssembler, + private val batchJobModelAssembler: BatchJobModelAssembler, + private val applicationContext: ApplicationContext, +) : RepresentationModelAssemblerSupport( + NotificationsController::class.java, + UserNotificationModel::class.java, + ) { + override fun toModel(entity: UserNotification): UserNotificationModel { + val project = entity.project?.let { simpleProjectModelAssembler.toModel(it) } + val modifiedEntities = assembleEntityChanges(entity.modifiedEntities).ifEmpty { null } + val batchJob = + entity.batchJob?.let { + val view = batchJobService.getView(it) + batchJobModelAssembler.toModel(view) + } + + return UserNotificationModel( + id = entity.id, + type = entity.type, + project = project, + batchJob = batchJob, + modifiedEntities = modifiedEntities, + unread = entity.unread, + markedDoneAt = entity.markedDoneAt, + lastUpdated = entity.lastUpdated, + ) + } + + private fun assembleEntityChanges(modifiedEntities: List): List { + val provider = + ModifiedEntitiesViewProvider( + applicationContext, + modifiedEntities, + ) + + return provider.getSimple() + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModel.kt index ecf53b02e4..9616fd5537 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModel.kt @@ -6,7 +6,7 @@ import org.springframework.hateoas.RepresentationModel data class SimpleUserAccountModel( val id: Long, val username: String, - var name: String?, + var name: String, var avatar: Avatar?, var deleted: Boolean, ) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModelAssembler.kt index f7c10b3a56..89390c0255 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModelAssembler.kt @@ -1,8 +1,8 @@ package io.tolgee.hateoas.userAccount +import io.tolgee.api.SimpleUserAccount import io.tolgee.api.v2.controllers.V2UserController import io.tolgee.dtos.cacheable.UserAccountDto -import io.tolgee.model.UserAccount import io.tolgee.service.AvatarService import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport import org.springframework.stereotype.Component @@ -10,11 +10,11 @@ import org.springframework.stereotype.Component @Component class SimpleUserAccountModelAssembler( private val avatarService: AvatarService, -) : RepresentationModelAssemblerSupport( +) : RepresentationModelAssemblerSupport( V2UserController::class.java, SimpleUserAccountModel::class.java, ) { - override fun toModel(entity: UserAccount): SimpleUserAccountModel { + override fun toModel(entity: SimpleUserAccount): SimpleUserAccountModel { val avatar = avatarService.getAvatarLinks(entity.avatarHash) return SimpleUserAccountModel( @@ -22,7 +22,7 @@ class SimpleUserAccountModelAssembler( username = entity.username, name = entity.name, avatar = avatar, - deleted = entity.deletedAt != null, + deleted = entity.deleted, ) } diff --git a/backend/app/build.gradle b/backend/app/build.gradle index 1575e7cafc..d305e5c49e 100644 --- a/backend/app/build.gradle +++ b/backend/app/build.gradle @@ -100,12 +100,13 @@ dependencies { exclude group: 'io.netty', module: 'netty-codec-http' } testImplementation('io.netty:netty-codec-http:4.1.108.Final') - + testImplementation libs.springJooq /** * MISC */ implementation libs.commonsCodec + implementation libs.commonsText implementation libs.amazonS3 implementation libs.amazonSTS implementation libs.icu4j diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiGroupBuilder.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiGroupBuilder.kt index 9ec6868f80..2cd2e69a6d 100644 --- a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiGroupBuilder.kt +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiGroupBuilder.kt @@ -3,6 +3,8 @@ package io.tolgee.configuration.openApi import io.swagger.v3.oas.models.Operation import io.swagger.v3.oas.models.PathItem import io.swagger.v3.oas.models.Paths +import io.tolgee.configuration.openApi.activity.ActivityGroupModelEnhancer +import io.tolgee.configuration.openApi.activity.ProjectActivityModelEnhancer import io.tolgee.openApiDocs.OpenApiCloudExtension import io.tolgee.openApiDocs.OpenApiEeExtension import io.tolgee.openApiDocs.OpenApiOrderExtension @@ -37,11 +39,20 @@ class OpenApiGroupBuilder( addExtensions() + updateActivitySchema() + cleanUnusedModels() return@lazy builder.build() } + private fun updateActivitySchema() { + builder.addOpenApiCustomizer { + ProjectActivityModelEnhancer(it).enhance() + ActivityGroupModelEnhancer(it).enhance() + } + } + private fun cleanUnusedModels() { builder.addOpenApiCustomizer { OpenApiUnusedSchemaCleaner(it).clean() diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/activity/ActivityGroupModelEnhancer.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/activity/ActivityGroupModelEnhancer.kt new file mode 100644 index 0000000000..a3bc50a8f7 --- /dev/null +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/activity/ActivityGroupModelEnhancer.kt @@ -0,0 +1,116 @@ +package io.tolgee.configuration.openApi.activity + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.media.Schema +import io.tolgee.activity.groups.ActivityGroupType +import org.apache.commons.text.CaseUtils + +class ActivityGroupModelEnhancer( + private val openApi: OpenAPI, +) { + private val baseSchema = + openApi.components.schemas["ActivityGroupModel"] + ?: throw IllegalStateException("ProjectActivityModel schema not found") + + private val baseProperties = baseSchema.properties + + private val baseType = baseSchema.type + + private val newRequired = baseSchema.required.toMutableList() + + fun enhance() { + baseSchema.required = null + baseSchema.properties = null + baseSchema.type = null + baseSchema.oneOf = generateSchemas() + } + + private fun generateSchemas(): MutableList> { + return ActivityGroupType.entries.map { + val schemaName = it.getSchemaName() + Schema().apply { + name = schemaName + properties = getPropertiesForActivitySchema(it) + type = baseType + required = newRequired + } + }.toMutableList().also { schemas -> + openApi.components.schemas.putAll(schemas.associateBy { it.name }) + } + } + + private fun getPropertiesForActivitySchema(type: ActivityGroupType): MutableMap> { + val newProperties = baseProperties.toMutableMap() + newProperties["type"] = getNewTypeProperty(newProperties, type) +// adjustCountsSchema(newProperties, type) + adjustDataSchema(type, newProperties) + return newProperties + } + +// private fun adjustCountsSchema( +// newProperties: MutableMap>, +// type: ActivityGroupType +// ) { +// val countPropertyType = +// newProperties["counts"]?.additionalProperties as? Schema<*> +// ?: throw IllegalStateException("Counts property not found") +// newProperties["counts"] = Schema().also { schema -> +// schema.type = "object" +// schema.properties = +// type.modifications.filter { it.countInView }.also { +// if (it.isNotEmpty()) { +// newRequired.add("counts") +// } +// }.associate { +// val className = it.entityClass.simpleName!! +// schema.addToRequired(className) +// className to countPropertyType +// } +// } +// } + + private fun Schema.addToRequired(className: String) { + if (required == null) { + required = mutableListOf(className) + return + } + required.add(className) + } + + private fun adjustDataSchema( + type: ActivityGroupType, + newProperties: MutableMap>, + ) { + val dataModel = getDataSchema(type) + if (dataModel != null) { + newProperties["data"] = dataModel + } else { + newProperties.remove("data") + } + } + + private fun getDataSchema(type: ActivityGroupType): Schema<*>? { + val modelType = type.getProvidingModelTypes()?.first + return modelType?.let { getEntitySchema(openApi, it) } + } + + private fun getNewTypeProperty( + properties: Map?>, + activityType: ActivityGroupType, + ): Schema<*> { + val oldTypeProperty = properties["type"] ?: throw IllegalStateException("Type property not found") + val newType = oldTypeProperty.clone() + @Suppress("TYPE_MISMATCH_WARNING") + newType.enum = newType.enum.filter { it == activityType.name } + return newType + } + + fun Schema<*>.clone(): Schema<*> { + val objectMapper = jacksonObjectMapper() + return objectMapper.readValue(objectMapper.writeValueAsString(this), Schema::class.java) + } + + private fun ActivityGroupType.getSchemaName() = + "ActivityGroup" + CaseUtils.toCamelCase(this.name, true, '_') + "Model" +} diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/activity/ModificationsSchemaGenerator.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/activity/ModificationsSchemaGenerator.kt new file mode 100644 index 0000000000..94bc3fead2 --- /dev/null +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/activity/ModificationsSchemaGenerator.kt @@ -0,0 +1,132 @@ +package io.tolgee.configuration.openApi.activity + +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.media.Schema +import io.tolgee.activity.annotation.ActivityDescribingProp +import io.tolgee.activity.annotation.ActivityLoggedProp +import io.tolgee.activity.data.EntityDescription +import io.tolgee.activity.data.EntityModificationTypeDefinition +import kotlin.reflect.KClass +import kotlin.reflect.KClassifier +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.isSubclassOf +import kotlin.reflect.full.memberProperties + +class ModificationsSchemaGenerator( + private val openAPI: OpenAPI, +) { + fun getModificationSchema( + entityClass: KClass<*>, + definition: EntityModificationTypeDefinition<*>, + ): Schema<*> { + val schema = getEntitySchema(openAPI, entityClass) + schema.required = emptyList() + val properties = getProperties(entityClass, schema) + + if (definition.isOnlyCreation()) { + properties.forEach { (_, prop) -> + prop.properties["old"] = createNullSchema() + } + } + + schema.properties = properties + + return schema + } + + private fun createNullSchema(): Schema { + val schema = Schema() + schema.type = "null" + return schema + } + + private fun EntityModificationTypeDefinition<*>.isOnlyCreation(): Boolean { + return creation && !deletion && modificationProps.isNullOrEmpty() + } + + private fun getProperties( + entityClass: KClass<*>, + schema: Schema<*>, + ): Map> { + val loggedProps = getAllLoggedProps(entityClass) + val simplePropNames = loggedProps.getSimpleProps().map { it.name } + val schemaSimpleProps = schema.properties?.filterKeys { it in simplePropNames } ?: emptyMap() + + val singlePropChangeMap = + schemaSimpleProps.map { (name, prop) -> + name to prop.toChangeSchema() + }.toMap() + + val complexProps = loggedProps.getComplexProps() + val complexPropChangeMap = + complexProps.map { + it.name to getModificationSchemaForComplexProp(it.returnType.classifier as KClass<*>) + }.toMap() + + return singlePropChangeMap + complexPropChangeMap + } + + private fun getModificationSchemaForComplexProp(it: KClass<*>): Schema<*> { + val describingProps = it.getDescriptionProps().map { it.name } + val entitySchema = getEntitySchema(openAPI, it) + val schemaDescribingProps = + entitySchema.properties?.filterKeys { propertyName -> propertyName in describingProps } + descriptionSchema.properties?.get("data")?.let { dataProp -> + dataProp.properties = schemaDescribingProps + dataProp.additionalProperties = null + } + descriptionSchema.additionalProperties = null + return descriptionSchema.toChangeSchema() + } + + private fun Schema<*>.toChangeSchema(): Schema<*> { + val changeSchema = Schema() + changeSchema.addProperty("old", this) + changeSchema.addProperty("new", this) + return changeSchema + } + + private fun getAllLoggedProps(entityClass: KClass<*>): List> { + return entityClass.memberProperties + .filter { it.findAnnotation() != null } + .map { it } + } + + private fun KClass<*>.getDescriptionProps(): List> { + return memberProperties + .filter { it.findAnnotation() != null } + .map { it }.filter { it.returnType.classifier.isSimpleType() } + } + + private fun List>.getSimpleProps(): List> { + return this + .filter { prop -> + prop.returnType.classifier.isSimpleType() + } + } + + private fun List>.getComplexProps(): List> { + return this + .filter { prop -> + !prop.returnType.classifier.isSimpleType() + } + } + + private val descriptionSchema by lazy { + getEntitySchema(openAPI, EntityDescription::class) + } + + private fun KClassifier?.isSimpleType(): Boolean { + return simpleTypes.any { (this as? KClass<*>)?.isSubclassOf(it) == true } + } + + companion object { + val simpleTypes = + setOf( + Int::class, Long::class, Double::class, Float::class, + Boolean::class, Char::class, Byte::class, Short::class, + String::class, Enum::class, Map::class, + ) + } +} diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/activity/ProjectActivityModelEnhancer.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/activity/ProjectActivityModelEnhancer.kt new file mode 100644 index 0000000000..77318ec9a4 --- /dev/null +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/activity/ProjectActivityModelEnhancer.kt @@ -0,0 +1,100 @@ +package io.tolgee.configuration.openApi.activity + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.media.Schema +import io.tolgee.activity.data.ActivityType +import org.apache.commons.text.CaseUtils +import kotlin.reflect.KClass + +class ProjectActivityModelEnhancer( + private val openApi: OpenAPI, +) { + private val baseSchema = + openApi.components.schemas["ProjectActivityModel"] + ?: throw IllegalStateException("ProjectActivityModel schema not found") + + private val modifiedEntityModel = + openApi.components.schemas["ModifiedEntityModel"] + ?: throw IllegalStateException("ModifiedEntityModel schema not found") + + private val baseRequired = baseSchema.required + + private val baseProperties = baseSchema.properties + + private val baseType = baseSchema.type + + fun enhance() { + baseSchema.required = null + baseSchema.properties = null + baseSchema.type = null + baseSchema.oneOf = generateSchemas() + } + + private fun generateSchemas(): MutableList> { + return ActivityType.entries.map { + val schemaName = it.getSchemaName() + Schema().apply { + name = schemaName + properties = getPropertiesForActivitySchema(it) + type = baseType + required = baseRequired + } + }.toMutableList().also { schemas -> + openApi.components.schemas.putAll(schemas.associateBy { it.name }) + } + } + + private fun getPropertiesForActivitySchema(activityType: ActivityType): MutableMap> { + val newProperties = baseProperties.toMutableMap() + newProperties["type"] = getNewTypeProperty(newProperties, activityType) + newProperties["modifiedEntities"] = getNewModifiedEntitiesProperty(activityType) + return newProperties + } + + private fun getNewModifiedEntitiesProperty(activityType: ActivityType): Schema { + val properties = + activityType.typeDefinitions?.map { (entityClass, definition) -> + val schema = activityType.createModifiedEntityModel(entityClass) + schema.properties["modifications"] = ModificationsSchemaGenerator(openApi).getModificationSchema(entityClass, definition) + entityClass.simpleName to schema + }?.toMap() + + return Schema().apply { + name = activityType.getModifiedEntitiesSchemaName() + this.properties = properties + } + } + + private fun getNewTypeProperty( + properties: Map?>, + activityType: ActivityType, + ): Schema<*> { + val oldTypeProperty = properties["type"] ?: throw IllegalStateException("Type property not found") + val newType = oldTypeProperty.clone() + @Suppress("TYPE_MISMATCH_WARNING") + newType.enum = newType.enum.filter { it == activityType.name } + return newType + } + + fun Schema<*>.clone(): Schema<*> { + val objectMapper = jacksonObjectMapper() + return objectMapper.readValue(objectMapper.writeValueAsString(this), Schema::class.java) + } + + private fun ActivityType.createModifiedEntityModel(entityClass: KClass<*>): Schema<*> { + return Schema().apply { + name = this@createModifiedEntityModel.getModifiedEntitySchemaName(entityClass) + properties = modifiedEntityModel.properties.toMutableMap() + } + } + + private fun ActivityType.getModifiedEntitySchemaName(entityClass: KClass<*>): String { + return "ModifiedEntity" + CaseUtils.toCamelCase(this.name, true, '_') + entityClass.simpleName + "Model" + } + + private fun ActivityType.getSchemaName() = "ProjectActivity" + CaseUtils.toCamelCase(this.name, true, '_') + "Model" + + private fun ActivityType.getModifiedEntitiesSchemaName() = + "ModifiedEntities" + CaseUtils.toCamelCase(this.name, true, '_') + "Model" +} diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/activity/tools.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/activity/tools.kt new file mode 100644 index 0000000000..b083ec1beb --- /dev/null +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/activity/tools.kt @@ -0,0 +1,20 @@ +package io.tolgee.configuration.openApi.activity + +import io.swagger.v3.core.converter.AnnotatedType +import io.swagger.v3.core.converter.ModelConverters +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.media.Schema +import kotlin.reflect.KClass + +fun getEntitySchema( + openApi: OpenAPI, + entityClass: KClass<*>, +): Schema<*> { + val resolved = + ModelConverters.getInstance() + .readAllAsResolvedSchema(AnnotatedType(entityClass.java)) + + resolved.referencedSchemas.forEach(openApi.components.schemas::putIfAbsent) + + return resolved.schema ?: Schema() +} diff --git a/backend/app/src/main/kotlin/io/tolgee/postgresRunners/PostgresDockerRunner.kt b/backend/app/src/main/kotlin/io/tolgee/postgresRunners/PostgresDockerRunner.kt index 80f5572137..59a8c8accb 100644 --- a/backend/app/src/main/kotlin/io/tolgee/postgresRunners/PostgresDockerRunner.kt +++ b/backend/app/src/main/kotlin/io/tolgee/postgresRunners/PostgresDockerRunner.kt @@ -53,6 +53,7 @@ class PostgresDockerRunner( get() = instance?.containerExisted != true || postgresAutostartProperties.stop override val datasourceUrl by lazy { - "jdbc:postgresql://localhost:${postgresAutostartProperties.port}/${postgresAutostartProperties.databaseName}" + "jdbc:postgresql://localhost:${postgresAutostartProperties.port}/${postgresAutostartProperties.databaseName}" + + "?reWriteBatchedInserts=true" } } diff --git a/backend/app/src/main/kotlin/io/tolgee/postgresRunners/PostgresEmbeddedRunner.kt b/backend/app/src/main/kotlin/io/tolgee/postgresRunners/PostgresEmbeddedRunner.kt index 6638c97da3..81ddc1659b 100644 --- a/backend/app/src/main/kotlin/io/tolgee/postgresRunners/PostgresEmbeddedRunner.kt +++ b/backend/app/src/main/kotlin/io/tolgee/postgresRunners/PostgresEmbeddedRunner.kt @@ -86,7 +86,7 @@ class PostgresEmbeddedRunner( override val datasourceUrl by lazy { // It's not that easy to change port in embedded version, since there is no env prop for that - "jdbc:postgresql://localhost:$POSTGRES_PORT/${postgresAutostartProperties.databaseName}" + "jdbc:postgresql://localhost:$POSTGRES_PORT/${postgresAutostartProperties.databaseName}?reWriteBatchedInserts=true" } private fun isPostgresUp(): Boolean { diff --git a/backend/app/src/main/resources/application.yaml b/backend/app/src/main/resources/application.yaml index bce10787ab..ec980fe72b 100644 --- a/backend/app/src/main/resources/application.yaml +++ b/backend/app/src/main/resources/application.yaml @@ -33,6 +33,10 @@ spring: enabled: false jdbc: initialize-schema: always + datasource: + auto-commit: false + jooq: + sql-dialect: postgres tolgee: authentication: enabled: false @@ -63,3 +67,6 @@ management: web: exposure: include: health,info,prometheus +logging: + level: + org.jooq.Constants: off diff --git a/backend/app/src/test/kotlin/io/tolgee/activity/RootActivityProviderTest.kt b/backend/app/src/test/kotlin/io/tolgee/activity/RootActivityProviderTest.kt new file mode 100644 index 0000000000..af6548bac1 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/activity/RootActivityProviderTest.kt @@ -0,0 +1,46 @@ +package io.tolgee.activity + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.activity.rootActivity.KeyActivityTreeDefinitionItem +import io.tolgee.activity.rootActivity.RootActivityProvider +import io.tolgee.development.testDataBuilder.data.dataImport.ImportTestData +import io.tolgee.fixtures.andIsOk +import org.junit.jupiter.api.Test +import org.springframework.data.domain.Pageable + +class RootActivityProviderTest : ProjectAuthControllerTest("/v2/projects/") { + @Test + fun `returns rooted activity`() { + importData() + val latestRevisionId = getLatestRevisionId()!! + val items = + RootActivityProvider( + applicationContext, + listOf(latestRevisionId), + KeyActivityTreeDefinitionItem, + Pageable.ofSize(100), + ).provide() + items + } + + private fun getLatestRevisionId(): Long? { + return entityManager.createQuery( + "select max(r.id) from ActivityRevision r", + Long::class.java, + ).singleResult + } + + private fun importData() { + val testData = ImportTestData() + testData.addFilesWithNamespaces().importFrenchInNs.existingLanguage = testData.french + testData.addKeyMetadata() + testData.setAllResolved() + testData.setAllOverride() + testDataService.saveTestData(testData.root) + val user = testData.root.data.userAccounts[0].self + val projectId = testData.project.id + loginAsUser(user.username) + val path = "/v2/projects/$projectId/import/apply" + performAuthPut(path, null).andIsOk + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/activity/groups/ActivityGroupsCreationTest.kt b/backend/app/src/test/kotlin/io/tolgee/activity/groups/ActivityGroupsCreationTest.kt new file mode 100644 index 0000000000..583b06cc4e --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/activity/groups/ActivityGroupsCreationTest.kt @@ -0,0 +1,225 @@ +package io.tolgee.activity.groups + +import com.posthog.java.PostHog +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.batch.BatchJobService +import io.tolgee.development.testDataBuilder.data.BaseTestData +import io.tolgee.dtos.request.key.ComplexEditKeyDto +import io.tolgee.fixtures.andIsOk +import io.tolgee.model.activity.ActivityGroup +import io.tolgee.model.activity.ActivityModifiedEntity +import io.tolgee.model.activity.ActivityRevision +import io.tolgee.model.enums.AssignableTranslationState +import io.tolgee.model.key.Key +import io.tolgee.model.translation.Translation +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.testing.assert +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.mock.mockito.MockBean +import java.time.Duration + +class ActivityGroupsCreationTest : ProjectAuthControllerTest("/v2/projects/") { + private lateinit var testData: BaseTestData + + @MockBean + @Autowired + lateinit var postHog: PostHog + + @Autowired + lateinit var batchJobService: BatchJobService + + @Autowired + lateinit var activityGroupService: ActivityGroupService + + @BeforeEach + fun setup() { + Mockito.reset(postHog) + } + + lateinit var key: Key + + private fun prepareTestData() { + testData = BaseTestData() + testData.user.name = "Franta" + testData.projectBuilder.apply { + addKey { + name = "key" + this@ActivityGroupsCreationTest.key = this + } + } + testDataService.saveTestData(testData.root) + projectSupplier = { testData.projectBuilder.self } + userAccount = testData.user + } + + @Test + @ProjectJWTAuthTestMethod + fun `it creates the groups in time windows`() { + prepareTestData() + assertItGroupsInTimeWindow() + assertItDoesNotGroupOutOfTimeWindow() + assertItStopsGroupingDueToAge() + } + + @Test + @ProjectJWTAuthTestMethod + fun `it creates correct groups per complex update`() { + prepareTestData() + performProjectAuthPut( + "keys/${key.id}/complex-update", + ComplexEditKeyDto( + name = "new name", + description = "Changed!", + tags = listOf("tag1", "tag2"), + translations = mapOf(testData.englishLanguage.tag to "Test"), + states = mapOf(testData.englishLanguage.tag to AssignableTranslationState.REVIEWED), + ), + ).andIsOk + + assertGroupsForActivity( + ActivityGroupType.EDIT_KEY_NAME, + ActivityGroupType.EDIT_KEY_TAGS, + ActivityGroupType.SET_TRANSLATIONS, + ActivityGroupType.REVIEW, + ) + } + + private fun assertItStopsGroupingDueToAge() { + currentDateProvider.move(Duration.ofHours(3)) + + (1..23).forEach { + executeTranslationUpdate("Test $it") + currentDateProvider.move(Duration.ofHours(1)) + } + + assertGroupRevisionsCount(23) + + currentDateProvider.move(Duration.ofHours(2)) + executeTranslationUpdate("Test final") + assertGroupRevisionsCount(1) + } + + private fun assertGroupsForActivity(vararg types: ActivityGroupType) { + val activityRevision = findLastActivityRevision() + val groups = getActivityGroupsForRevision(activityRevision) + groups.map { it.type }.assert.containsExactlyInAnyOrder(*types) + } + + private fun assertItDoesNotGroupOutOfTimeWindow() { + // it's not the same group anymore + currentDateProvider.move(Duration.ofMinutes(123)) + + executeTranslationUpdate("Test 4") + assertGroupRevisionsCount(1) + assertLastGroupType(ActivityGroupType.SET_TRANSLATIONS) + assertLastValue("Test 4") + } + + private fun assertItGroupsInTimeWindow() { + executeTranslationUpdate("Test") + executeTranslationUpdate("Test 2") + + currentDateProvider.move(Duration.ofMinutes(65)) + // still the same group + executeTranslationUpdate("Test 3") + + asserGroupCount(1) + assertGroupRevisionsCount(3) + assertLastGroupType(ActivityGroupType.SET_TRANSLATIONS) + assertLastValue("Test 3") + } + + private fun assertGroupRevisionsCount(count: Int) { + executeInNewTransaction { + val activityRevision = findLastActivityRevision() + val groups = getActivityGroupsForRevision(activityRevision) + val group = groups.single() + entityManager.createQuery( + """ + select count(ar) from ActivityRevision ar + join ar.activityGroups ag + where ag.id = :groupId + """, + ) + .setParameter("groupId", group.id) + .singleResult + .assert.isEqualTo(count.toLong()) + } + } + + private fun asserGroupCount(count: Int) { + executeInNewTransaction { + val activityRevision = findLastActivityRevision() + val groups = getActivityGroupsForRevision(activityRevision) + groups.size.assert.isEqualTo(count) + } + } + + private fun assertLastGroupType(activityGroupType: ActivityGroupType) { + executeInNewTransaction { + val activityRevision = findLastActivityRevision() + val groups = getActivityGroupsForRevision(activityRevision) + groups.single().type.assert.isEqualTo(activityGroupType) + } + } + + private fun assertLastValue(value: String) { + executeInNewTransaction { + val activityRevision = findLastActivityRevision() + val groups = getActivityGroupsForRevision(activityRevision) + val group = groups.single() + val modifiedEntities = getModifiedEntitiesForGroup(group) + modifiedEntities.filter { it.entityClass == Translation::class.simpleName } + .last() + .modifications["text"]!! + .new.assert.isEqualTo(value) + } + } + + private fun executeTranslationUpdate(value: String) { + performProjectAuthPut( + "translations", + mapOf("key" to "key", "translations" to mapOf(testData.englishLanguage.tag to value)), + ).andIsOk + } + + private fun findLastActivityRevision(): ActivityRevision { + return entityManager.createQuery( + """ + select ar from ActivityRevision ar + order by ar.timestamp desc + limit 1 + """, + ActivityRevision::class.java, + ).singleResult + } + + private fun getActivityGroupsForRevision(activityRevision: ActivityRevision): MutableList { + return entityManager.createQuery( + """ + select ag from ActivityGroup ag + join fetch ag.activityRevisions ar + where ar.id = :activityRevisionId + """, + ActivityGroup::class.java, + ).setParameter("activityRevisionId", activityRevision.id) + .resultList + } + + private fun getModifiedEntitiesForGroup(group: ActivityGroup): MutableList { + return entityManager.createQuery( + """ + select ame from ActivityModifiedEntity ame + join fetch ame.activityRevision ar + join fetch ar.activityGroups ag + where ag.id = :groupId + order by ar.timestamp + """, + ActivityModifiedEntity::class.java, + ).setParameter("groupId", group.id) + .resultList + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/activity/groups/ProjectCreationGroupViewTest.kt b/backend/app/src/test/kotlin/io/tolgee/activity/groups/ProjectCreationGroupViewTest.kt new file mode 100644 index 0000000000..fc04294c6c --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/activity/groups/ProjectCreationGroupViewTest.kt @@ -0,0 +1,107 @@ +package io.tolgee.activity.groups + +import io.tolgee.batch.BatchJobService +import io.tolgee.development.testDataBuilder.data.BaseTestData +import io.tolgee.dtos.request.LanguageRequest +import io.tolgee.dtos.request.project.CreateProjectRequest +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andGetContentAsJsonMap +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.isValidId +import io.tolgee.fixtures.node +import io.tolgee.model.key.Key +import io.tolgee.testing.AuthorizedControllerTest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import java.math.BigDecimal + +class ProjectCreationGroupViewTest : AuthorizedControllerTest() { + private lateinit var testData: BaseTestData + + @Autowired + lateinit var batchJobService: BatchJobService + + lateinit var key: Key + + private fun prepareTestData() { + testData = BaseTestData() + testDataService.saveTestData(testData.root) + } + + @Test + fun `shows correct project creation group`() { + prepareTestData() + userAccount = testData.user + val projectId = performProjectCreation() + performAuthGet("/v2/projects/$projectId/activity/groups") + .andIsOk.andAssertThatJson { + node("_embedded.groups") { + isArray.hasSizeGreaterThan(0) + node("[0]") { + node("id").isNumber + node("timestamp").isNumber.isGreaterThan(BigDecimal("1722441564241")) + node("type").isEqualTo("CREATE_PROJECT") + + node("author") { + node("id").isNumber + node("username").isEqualTo("test_username") + node("name").isEqualTo("") + node("avatar").isNull() + node("deleted").isEqualTo(false) + } + + node("counts.Project").isEqualTo(1) + + node("data") { + node("id").isNumber + node("name").isEqualTo("What a project") + node("languages") { + isArray.hasSize(2) + node("[0]") { + node("id").isValidId + node("name").isEqualTo("English") + node("originalName").isEqualTo("English") + node("tag").isEqualTo("en") + node("flagEmoji").isEqualTo("a") + } + node("[1]") { + node("id").isValidId + node("name").isEqualTo("Czech") + node("originalName").isEqualTo("česky") + node("tag").isEqualTo("cs") + node("flagEmoji").isEqualTo("b") + } + } + node("description").isNull() + } + } + } + } + } + + private fun performProjectCreation(): Int { + return performAuthPost( + "/v2/projects", + CreateProjectRequest( + name = "What a project", + organizationId = testData.project.organizationOwner.id, + languages = + listOf( + LanguageRequest( + name = "English", + originalName = "English", + tag = "en", + flagEmoji = "a", + ), + LanguageRequest( + name = "Czech", + originalName = "česky", + tag = "cs", + flagEmoji = "b", + ), + ), + baseLanguageTag = "cs", + ), + ).andIsOk.andGetContentAsJsonMap["id"] as Int + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2UserControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2UserControllerTest.kt index 2011eef4bd..1998be3142 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2UserControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2UserControllerTest.kt @@ -16,6 +16,7 @@ import io.tolgee.testing.AuthorizedControllerTest import io.tolgee.testing.ContextRecreatingTest import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.testing.satisfies import org.assertj.core.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -23,7 +24,6 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.test.web.servlet.result.MockMvcResultMatchers -import java.util.* @ContextRecreatingTest @SpringBootTest( diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/contentDelivery/ContentDeliveryConfigControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/contentDelivery/ContentDeliveryConfigControllerTest.kt index 2ee31ba14a..773c80b510 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/contentDelivery/ContentDeliveryConfigControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/contentDelivery/ContentDeliveryConfigControllerTest.kt @@ -21,6 +21,7 @@ import io.tolgee.service.contentDelivery.ContentDeliveryConfigService import io.tolgee.testing.ContextRecreatingTest import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod import io.tolgee.testing.assert +import io.tolgee.testing.satisfies import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationControllerInvitingTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationControllerInvitingTest.kt index d188069a90..5c3e107ed5 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationControllerInvitingTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationControllerInvitingTest.kt @@ -17,6 +17,7 @@ import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.testing.AuthorizedControllerTest import io.tolgee.testing.assertions.Assertions.assertThat import io.tolgee.testing.assertions.Assertions.assertThatThrownBy +import io.tolgee.testing.satisfies import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationControllerTest.kt index 7ae6542fe2..c583d553be 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationControllerTest.kt @@ -3,12 +3,22 @@ package io.tolgee.api.v2.controllers.organizationController import io.tolgee.development.testDataBuilder.data.OrganizationTestData import io.tolgee.dtos.request.organization.OrganizationDto import io.tolgee.dtos.request.organization.SetOrganizationRoleDto -import io.tolgee.fixtures.* +import io.tolgee.fixtures.andAssertError +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsBadRequest +import io.tolgee.fixtures.andIsCreated +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsNotFound +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.andPrettyPrint +import io.tolgee.fixtures.isPermissionScopes +import io.tolgee.fixtures.node import io.tolgee.model.Organization import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.model.enums.ProjectPermissionType import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.testing.satisfiesIf import org.junit.jupiter.api.Test import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest @@ -171,7 +181,7 @@ class OrganizationControllerTest : BaseOrganizationControllerTest() { node("name").isEqualTo("Test org") node("slug").isEqualTo("test-org") node("_links.self.href").isEqualTo("http://localhost/v2/organizations/test-org") - node("id").isNumber.satisfies { + node("id").isNumber.satisfiesIf { organizationService.find(it.toLong()) is Organization } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerCursorTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerCursorTest.kt index ecfb0b8dab..f0d01db4c1 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerCursorTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerCursorTest.kt @@ -8,6 +8,7 @@ import io.tolgee.fixtures.andIsOk import io.tolgee.fixtures.andPrettyPrint import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.testing.satisfies import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerModificationTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerModificationTest.kt index 92d490c44d..654ba2e16f 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerModificationTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerModificationTest.kt @@ -17,6 +17,7 @@ import io.tolgee.testing.annotations.ProjectApiKeyAuthTestMethod import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.testing.satisfies import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImageUploadController/SecuredV2ImageUploadControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImageUploadController/SecuredV2ImageUploadControllerTest.kt index d27a75e50b..0f01a5071b 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImageUploadController/SecuredV2ImageUploadControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImageUploadController/SecuredV2ImageUploadControllerTest.kt @@ -4,18 +4,23 @@ package io.tolgee.api.v2.controllers.v2ImageUploadController -import io.tolgee.fixtures.* +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsCreated +import io.tolgee.fixtures.andIsNotFound +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.andIsUnauthorized +import io.tolgee.fixtures.andPrettyPrint import io.tolgee.security.authentication.JwtService import io.tolgee.testing.ContextRecreatingTest import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.testing.satisfies import org.assertj.core.data.Offset import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.springframework.boot.test.context.SpringBootTest import java.time.Duration -import java.util.* @TestInstance(TestInstance.Lifecycle.PER_CLASS) @ContextRecreatingTest diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImageUploadController/V2ImageUploadControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImageUploadController/V2ImageUploadControllerTest.kt index a9905114c0..e84850b669 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImageUploadController/V2ImageUploadControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImageUploadController/V2ImageUploadControllerTest.kt @@ -13,6 +13,7 @@ import io.tolgee.fixtures.andIsOk import io.tolgee.fixtures.andPrettyPrint import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.testing.satisfies import org.assertj.core.data.Offset import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerCreationTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerCreationTest.kt index 3207ac7ddf..19fe9a3e0a 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerCreationTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerCreationTest.kt @@ -24,6 +24,7 @@ import io.tolgee.testing.annotations.ProjectApiKeyAuthTestMethod import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.testing.satisfies import io.tolgee.util.generateImage import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerCreateTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerCreateTest.kt index 5151156059..623f246814 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerCreateTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerCreateTest.kt @@ -11,6 +11,7 @@ import io.tolgee.fixtures.andIsOk import io.tolgee.fixtures.andPrettyPrint import io.tolgee.testing.AuthorizedControllerTest import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.testing.satisfies import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerInvitationTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerInvitationTest.kt index 4e59309d6a..ab006abcec 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerInvitationTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerInvitationTest.kt @@ -83,9 +83,12 @@ class ProjectsControllerInvitationTest : ProjectAuthControllerTest("/v2/projects type = ProjectPermissionType.TRANSLATE languages = setOf(getLang("en")) }.andIsOk - val invitation = invitationTestUtil.getInvitation(result) - invitation.permission?.translateLanguages!!.map { it.tag }.assert.contains("en") // stores - invitation.permission?.viewLanguages!!.map { it.tag }.assert.contains() // ads also to view + + executeInNewTransaction { + val invitation = invitationTestUtil.getInvitation(result) + invitation.permission?.translateLanguages!!.map { it.tag }.assert.contains("en") // stores + invitation.permission?.viewLanguages!!.map { it.tag }.assert.contains() // ads also to view + } } @Test @@ -97,9 +100,12 @@ class ProjectsControllerInvitationTest : ProjectAuthControllerTest("/v2/projects translateLanguages = setOf(getLang("en")) stateChangeLanguages = setOf(getLang("en")) }.andIsOk - val invitation = invitationTestUtil.getInvitation(result) - invitation.permission?.stateChangeLanguages!!.map { it.tag }.assert.contains("en") // stores - invitation.permission?.viewLanguages!!.map { it.tag }.assert.contains() // ads also to view + + executeInNewTransaction { + val invitation = invitationTestUtil.getInvitation(result) + invitation.permission?.stateChangeLanguages!!.map { it.tag }.assert.contains("en") // stores + invitation.permission?.viewLanguages!!.map { it.tag }.assert.contains() // ads also to view + } } @Test diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerTest.kt index fb0edd44ab..4856f13322 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerTest.kt @@ -83,24 +83,26 @@ class ProjectsControllerTest : ProjectAuthControllerTest("/v2/projects/") { node("[0].organizationOwner.name").isEqualTo("test_username") node("[0].directPermission.scopes").isPermissionScopes(ProjectPermissionType.MANAGE) node("[0].computedPermission.scopes").isPermissionScopes(ProjectPermissionType.MANAGE) - node("[0].stats.translationStatePercentages").isEqualTo( - """ - { - "UNTRANSLATED": 100.0, - "TRANSLATED": 0, - "REVIEWED": 0 - } - """, - ) - node("[1].stats.translationStatePercentages").isEqualTo( - """ - { - "UNTRANSLATED": 25.0, - "TRANSLATED": 75.0, - "REVIEWED": 0.0 - } - """, - ) + node("[0].stats.translationStatePercentages") + .isEqualTo( + """ + { + "UNTRANSLATED": 100.0, + "TRANSLATED": 0, + "REVIEWED": 0 + } + """, + ) + node("[1].stats.translationStatePercentages") + .isEqualTo( + """ + { + "UNTRANSLATED": 25.0, + "TRANSLATED": 75.0, + "REVIEWED": 0 + } + """, + ) } } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ScreenshotController/KeyScreenshotControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ScreenshotController/KeyScreenshotControllerTest.kt index 4b5365e7af..6fc7974b8b 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ScreenshotController/KeyScreenshotControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ScreenshotController/KeyScreenshotControllerTest.kt @@ -13,6 +13,7 @@ import io.tolgee.fixtures.andPrettyPrint import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.testing.satisfies import io.tolgee.util.InMemoryFileStorage import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ScreenshotController/SecuredKeyScreenshotControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ScreenshotController/SecuredKeyScreenshotControllerTest.kt index 77284d42fc..7067e80a3f 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ScreenshotController/SecuredKeyScreenshotControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ScreenshotController/SecuredKeyScreenshotControllerTest.kt @@ -5,20 +5,25 @@ package io.tolgee.api.v2.controllers.v2ScreenshotController import io.tolgee.dtos.request.key.CreateKeyDto -import io.tolgee.fixtures.* +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsCreated +import io.tolgee.fixtures.andIsNotFound +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.andIsUnauthorized +import io.tolgee.fixtures.generateUniqueString import io.tolgee.model.Permission import io.tolgee.model.enums.Scope import io.tolgee.security.authentication.JwtService import io.tolgee.testing.ContextRecreatingTest import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.testing.satisfies import org.assertj.core.data.Offset import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.springframework.boot.test.context.SpringBootTest import java.time.Duration -import java.util.* @ContextRecreatingTest @SpringBootTest( diff --git a/backend/app/src/test/kotlin/io/tolgee/cache/AbstractCacheTest.kt b/backend/app/src/test/kotlin/io/tolgee/cache/AbstractCacheTest.kt index 93fc711fbb..32a63e5a62 100644 --- a/backend/app/src/test/kotlin/io/tolgee/cache/AbstractCacheTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/cache/AbstractCacheTest.kt @@ -122,7 +122,7 @@ abstract class AbstractCacheTest : AbstractSpringTest() { fun `caches permission by project and user`() { val permission = Permission(id = 1) whenever(permissionRepository.findOneByProjectIdAndUserIdAndOrganizationId(1, 1)) - .then { permission } + .then { Permission.PermissionWithLanguageIdsWrapper(permission, null, null, null) } permissionService.find(1, 1) Mockito.verify(permissionRepository, times(1)) .findOneByProjectIdAndUserIdAndOrganizationId(1, 1) @@ -137,7 +137,7 @@ abstract class AbstractCacheTest : AbstractSpringTest() { whenever( permissionRepository .findOneByProjectIdAndUserIdAndOrganizationId(null, null, organizationId = 1), - ).then { permission } + ).then { Permission.PermissionWithLanguageIdsWrapper(permission, null, null, null) } permissionService.find(organizationId = 1) Mockito.verify(permissionRepository, times(1)) diff --git a/backend/app/src/test/kotlin/io/tolgee/initialUserCreation/LegacyMigrationTest.kt b/backend/app/src/test/kotlin/io/tolgee/initialUserCreation/LegacyMigrationTest.kt index 25480726cf..97821db8dd 100644 --- a/backend/app/src/test/kotlin/io/tolgee/initialUserCreation/LegacyMigrationTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/initialUserCreation/LegacyMigrationTest.kt @@ -16,6 +16,7 @@ import io.tolgee.repository.UserAccountRepository import io.tolgee.service.security.SecurityService import io.tolgee.testing.ContextRecreatingTest import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.testing.satisfies import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.assertDoesNotThrow diff --git a/backend/app/src/test/kotlin/io/tolgee/notifications/AbstractNotificationTest.kt b/backend/app/src/test/kotlin/io/tolgee/notifications/AbstractNotificationTest.kt new file mode 100644 index 0000000000..67714651cf --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/notifications/AbstractNotificationTest.kt @@ -0,0 +1,102 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications + +import io.tolgee.repository.notifications.UserNotificationRepository +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assert +import io.tolgee.util.addMilliseconds +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.mock.mockito.SpyBean +import org.springframework.scheduling.TaskScheduler +import java.util.* +import java.util.concurrent.Semaphore +import java.util.concurrent.TimeUnit + +abstract class AbstractNotificationTest : AuthorizedControllerTest() { + @Autowired + lateinit var userNotificationService: UserNotificationService + + @Autowired + lateinit var taskScheduler: TaskScheduler + + @SpyBean + @Autowired + lateinit var userNotificationRepository: UserNotificationRepository + + lateinit var semaphore: Semaphore + + @BeforeEach + fun setupTests() { + semaphore = Semaphore(0) + + doAnswer { + entityManager.persist(it.arguments[0]) + entityManager.flush() + + // Wait a bit to make sure everything's *actually* persisted + // Kind of an ugly way to synchronize everything, but it is what it is + taskScheduler.schedule( + { semaphore.release() }, + Date().addMilliseconds(100).toInstant(), + ) + + it.arguments[0] + }.`when`(userNotificationRepository).save(any()) + + doAnswer { + val list = it.arguments[0] as List<*> + for (entity in list) entityManager.persist(entity) + entityManager.flush() + + for (entity in it.arguments[0] as List<*>) entityManager.refresh(entity) + + // Wait a bit to make sure everything's *actually* persisted + // Kind of an ugly way to synchronize everything, but it is what it is + taskScheduler.schedule( + { semaphore.release(list.size) }, + Date().addMilliseconds(100).toInstant(), + ) + + it.arguments[0] + }.`when`(userNotificationRepository).saveAll(Mockito.anyList()) + } + + @AfterEach + fun clearWatcher() { + Mockito.reset(userNotificationRepository) + } + + fun waitUntilUserNotificationDispatch(count: Int = 1) { + val dispatched = semaphore.tryAcquire(count, 1L, TimeUnit.SECONDS) + dispatched.assert + .withFailMessage("Expected at least $count notification(s) to be dispatched.") + .isTrue() + } + + fun ensureNoUserNotificationDispatch() { + val dispatched = semaphore.tryAcquire(1L, TimeUnit.SECONDS) + dispatched.assert + .withFailMessage("Expected no notifications to be dispatched.") + .isFalse() + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/notifications/RemappedNotificationsTest.kt b/backend/app/src/test/kotlin/io/tolgee/notifications/RemappedNotificationsTest.kt new file mode 100644 index 0000000000..e11f111477 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/notifications/RemappedNotificationsTest.kt @@ -0,0 +1,235 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications + +import io.tolgee.development.testDataBuilder.data.NotificationsTestData +import io.tolgee.dtos.request.key.ComplexEditKeyDto +import io.tolgee.dtos.request.key.KeyScreenshotDto +import io.tolgee.fixtures.andGetContentAsJsonMap +import io.tolgee.fixtures.andIsCreated +import io.tolgee.fixtures.andIsOk +import io.tolgee.testing.assert +import io.tolgee.util.generateImage +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockMultipartFile + +class RemappedNotificationsTest : AbstractNotificationTest() { + lateinit var testData: NotificationsTestData + + @BeforeEach + override fun setupTests() { + testData = NotificationsTestData() + testDataService.saveTestData(testData.root) + + super.setupTests() + } + + @Test + fun `it does properly remap imports to key and translation notifications`() { + performAuthMultipart( + url = "/v2/projects/${testData.project1.id}/import", + files = + listOf( + MockMultipartFile( + "files", + "en.json", + "application/json", + """{"new-key1": "New string 1", "new-key2": "New string 2"}""".toByteArray(), + ), + MockMultipartFile( + "files", + "fr.json", + "application/json", + """{"some-key": "Updated", "new-key1": "New FR string 1", "new-key2": "New FR string 2"}""".toByteArray(), + ), + MockMultipartFile( + "files", + "cs.json", + "application/json", + """{"new-key1": "New CZ string 1", "new-key2": "New CZ string 2"}""".toByteArray(), + ), + ), + ).andIsOk + + performAuthPut("/v2/projects/${testData.project1.id}/import/apply?forceMode=OVERRIDE", null).andIsOk + + waitUntilUserNotificationDispatch(12) + val acmeChiefNotifications = userNotificationRepository.findAllByRecipient(testData.orgAdmin) + val projectManagerNotifications = userNotificationRepository.findAllByRecipient(testData.projectManager) + val frenchTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.frenchTranslator) + val czechTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.czechTranslator) + val germanTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.germanTranslator) + val frenchCzechTranslatorNotifications = + userNotificationRepository.findAllByRecipient(testData.frenchCzechTranslator) + val bobNotifications = userNotificationRepository.findAllByRecipient(testData.bob) + + acmeChiefNotifications.assert.hasSize(2) + projectManagerNotifications.assert.hasSize(2) + frenchTranslatorNotifications.assert.hasSize(2) + czechTranslatorNotifications.assert.hasSize(1) + germanTranslatorNotifications.assert.hasSize(1) + frenchCzechTranslatorNotifications.assert.hasSize(2) + bobNotifications.assert.hasSize(2) + + acmeChiefNotifications.assert + .noneSatisfy { it.type.assert.isEqualTo(NotificationType.ACTIVITY_SOURCE_STRINGS_UPDATED) } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_TRANSLATIONS_UPDATED) + it.modifiedEntities.assert.hasSize(1) + } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_KEYS_CREATED) + it.modifiedEntities.assert.hasSize(8) // 2 keys + 6 translations + } + + projectManagerNotifications.assert + .noneSatisfy { it.type.assert.isEqualTo(NotificationType.ACTIVITY_SOURCE_STRINGS_UPDATED) } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_TRANSLATIONS_UPDATED) + it.modifiedEntities.assert.hasSize(1) + } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_KEYS_CREATED) + it.modifiedEntities.assert.hasSize(8) // 2 keys + 6 translations + } + + frenchTranslatorNotifications.assert + .noneSatisfy { it.type.assert.isEqualTo(NotificationType.ACTIVITY_SOURCE_STRINGS_UPDATED) } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_TRANSLATIONS_UPDATED) + it.modifiedEntities.assert.hasSize(1) + } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_KEYS_CREATED) + it.modifiedEntities.assert.hasSize(6) // 2 keys + 4 translations + } + + czechTranslatorNotifications.assert + .noneSatisfy { it.type.assert.isEqualTo(NotificationType.ACTIVITY_SOURCE_STRINGS_UPDATED) } + .noneSatisfy { it.type.assert.isEqualTo(NotificationType.ACTIVITY_TRANSLATIONS_UPDATED) } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_KEYS_CREATED) + it.modifiedEntities.assert.hasSize(6) // 2 keys + 4 translations + } + + germanTranslatorNotifications.assert + .noneSatisfy { it.type.assert.isEqualTo(NotificationType.ACTIVITY_SOURCE_STRINGS_UPDATED) } + .noneSatisfy { it.type.assert.isEqualTo(NotificationType.ACTIVITY_TRANSLATIONS_UPDATED) } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_KEYS_CREATED) + it.modifiedEntities.assert.hasSize(4) // 2 keys + 2 translations + } + + frenchCzechTranslatorNotifications.assert + .noneSatisfy { it.type.assert.isEqualTo(NotificationType.ACTIVITY_SOURCE_STRINGS_UPDATED) } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_TRANSLATIONS_UPDATED) + it.modifiedEntities.assert.hasSize(1) + } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_KEYS_CREATED) + it.modifiedEntities.assert.hasSize(8) // 2 keys + 6 translations + } + + bobNotifications.assert + .noneSatisfy { it.type.assert.isEqualTo(NotificationType.ACTIVITY_SOURCE_STRINGS_UPDATED) } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_TRANSLATIONS_UPDATED) + it.modifiedEntities.assert.hasSize(1) + } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_KEYS_CREATED) + it.modifiedEntities.assert.hasSize(8) // 2 keys + 6 translations + } + + ensureNoUserNotificationDispatch() + } + + @Test + fun `it does remap complex key edits to relevant notification types`() { + val screenshotId = + performAuthMultipart( + url = "/v2/image-upload", + files = + listOf( + MockMultipartFile( + "image", + "image.png", + "image/png", + generateImage(100, 100).inputStream.readAllBytes(), + ), + ), + ).andIsCreated.andGetContentAsJsonMap["id"].let { (it as Int).toLong() } + + performAuthPut( + "/v2/projects/${testData.project1.id}/keys/${testData.keyProject1.id}/complex-update", + ComplexEditKeyDto( + name = "new-name", + namespace = "new-namespace", + translations = mapOf("en" to "New EN string", "fr" to "New FR string"), + screenshotsToAdd = + listOf( + KeyScreenshotDto(uploadedImageId = screenshotId), + ), + ), + ).andIsOk + + waitUntilUserNotificationDispatch(25) + val acmeChiefNotifications = userNotificationRepository.findAllByRecipient(testData.orgAdmin) + val czechTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.czechTranslator) + val bobNotifications = userNotificationRepository.findAllByRecipient(testData.bob) + + bobNotifications.assert + .hasSize(3) + .noneSatisfy { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_KEYS_SCREENSHOTS_UPLOADED) + } + + acmeChiefNotifications.assert + .hasSize(4) + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_KEYS_UPDATED) + } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_KEYS_SCREENSHOTS_UPLOADED) + } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_SOURCE_STRINGS_UPDATED) + it.modifiedEntities.assert.hasSize(1) + } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_TRANSLATIONS_UPDATED) + it.modifiedEntities.assert.hasSize(1) + } + + czechTranslatorNotifications.assert + .hasSize(3) + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_KEYS_UPDATED) + } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_KEYS_SCREENSHOTS_UPLOADED) + } + .satisfiesOnlyOnce { + it.type.assert.isEqualTo(NotificationType.ACTIVITY_SOURCE_STRINGS_UPDATED) + it.modifiedEntities.assert.hasSize(1) + } + + ensureNoUserNotificationDispatch() + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/notifications/UserNotificationDebounceTest.kt b/backend/app/src/test/kotlin/io/tolgee/notifications/UserNotificationDebounceTest.kt new file mode 100644 index 0000000000..24eb7466ec --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/notifications/UserNotificationDebounceTest.kt @@ -0,0 +1,129 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications + +import io.tolgee.development.testDataBuilder.data.NotificationsTestData +import io.tolgee.dtos.request.LanguageRequest +import io.tolgee.dtos.request.key.CreateKeyDto +import io.tolgee.dtos.request.translation.comment.TranslationCommentWithLangKeyDto +import io.tolgee.fixtures.andIsCreated +import io.tolgee.fixtures.andIsOk +import io.tolgee.testing.assert +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class UserNotificationDebounceTest : AbstractNotificationTest() { + lateinit var testData: NotificationsTestData + + @BeforeEach + override fun setupTests() { + testData = NotificationsTestData() + testDataService.saveTestData(testData.root) + + super.setupTests() + } + + @Test + fun `it debounces notifications of the same type`() { + performAuthPost( + "/v2/projects/${testData.calmProject.id}/keys/create", + CreateKeyDto(name = "test-key-1"), + ).andIsCreated + + waitUntilUserNotificationDispatch() + userNotificationRepository.findAllByRecipient(testData.alice).assert.hasSize(1) + + performAuthPost( + "/v2/projects/${testData.calmProject.id}/keys/create", + CreateKeyDto(name = "test-key-2"), + ).andIsCreated + + waitUntilUserNotificationDispatch() + userNotificationRepository.findAllByRecipient(testData.alice).assert.hasSize(1) + + performAuthPost( + url = "/v2/projects/${testData.calmProject.id}/languages", + content = + LanguageRequest( + name = "Meow", + originalName = "meow", + tag = "meow-en", + ), + ).andIsOk + + waitUntilUserNotificationDispatch() + userNotificationRepository.findAllByRecipient(testData.alice).assert.hasSize(2) + } + + @Test + fun `it only debounces notifications within the same project`() { + performAuthPost( + "/v2/projects/${testData.calmProject.id}/keys/create", + CreateKeyDto(name = "test-key-1"), + ).andIsCreated + + waitUntilUserNotificationDispatch() + userNotificationRepository.findAllByRecipient(testData.alice).assert.hasSize(1) + + performAuthPost( + "/v2/projects/${testData.project2.id}/keys/create", + CreateKeyDto(name = "test-key-2"), + ).andIsCreated + + waitUntilUserNotificationDispatch() + userNotificationRepository.findAllByRecipient(testData.alice).assert.hasSize(2) + } + + @Test + fun `it debounces comments only when they are under the same translation`() { + performAuthPost( + "/v2/projects/${testData.calmProject.id}/translations/create-comment", + TranslationCommentWithLangKeyDto( + keyId = testData.keyCalmProject.id, + languageId = testData.keyCalmEnTranslation.language.id, + text = "This is a test", + ), + ).andIsCreated + + waitUntilUserNotificationDispatch() + userNotificationRepository.findAllByRecipient(testData.alice).assert.hasSize(1) + + performAuthPost( + "/v2/projects/${testData.calmProject.id}/translations/create-comment", + TranslationCommentWithLangKeyDto( + keyId = testData.keyCalmProject.id, + languageId = testData.keyCalmEnTranslation.language.id, + text = "This is a test 2", + ), + ).andIsCreated + + waitUntilUserNotificationDispatch() + userNotificationRepository.findAllByRecipient(testData.alice).assert.hasSize(1) + + performAuthPost( + "/v2/projects/${testData.calmProject.id}/translations/create-comment", + TranslationCommentWithLangKeyDto( + keyId = testData.keyCalmProject.id, + languageId = testData.calmProjectFr.id, + text = "This is a test", + ), + ).andIsCreated + + waitUntilUserNotificationDispatch() + userNotificationRepository.findAllByRecipient(testData.alice).assert.hasSize(2) + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/notifications/UserNotificationDispatchTest.kt b/backend/app/src/test/kotlin/io/tolgee/notifications/UserNotificationDispatchTest.kt new file mode 100644 index 0000000000..e6a4296de0 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/notifications/UserNotificationDispatchTest.kt @@ -0,0 +1,218 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications + +import io.tolgee.development.testDataBuilder.data.NotificationsTestData +import io.tolgee.dtos.request.key.CreateKeyDto +import io.tolgee.dtos.request.translation.SetTranslationsWithKeyDto +import io.tolgee.fixtures.andIsCreated +import io.tolgee.fixtures.andIsOk +import io.tolgee.testing.assert +import io.tolgee.util.generateImage +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockMultipartFile + +class UserNotificationDispatchTest : AbstractNotificationTest() { + lateinit var testData: NotificationsTestData + + @BeforeEach + override fun setupTests() { + testData = NotificationsTestData() + testDataService.saveTestData(testData.root) + + super.setupTests() + } + + @Test + fun `it dispatches notifications to everyone in project`() { + performAuthPost( + "/v2/projects/${testData.project1.id}/keys/create", + CreateKeyDto(name = "test-key"), + ).andIsCreated + + waitUntilUserNotificationDispatch(7) + val adminNotifications = userNotificationRepository.findAllByRecipient(testData.admin) + val acmeChiefNotifications = userNotificationRepository.findAllByRecipient(testData.orgAdmin) + val projectManagerNotifications = userNotificationRepository.findAllByRecipient(testData.projectManager) + val frenchTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.frenchTranslator) + val czechTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.czechTranslator) + val germanTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.germanTranslator) + val frenchCzechTranslatorNotifications = + userNotificationRepository.findAllByRecipient(testData.frenchCzechTranslator) + val bobNotifications = userNotificationRepository.findAllByRecipient(testData.bob) + val aliceNotifications = userNotificationRepository.findAllByRecipient(testData.alice) + + adminNotifications.assert.hasSize(0) + acmeChiefNotifications.assert.hasSize(1) + projectManagerNotifications.assert.hasSize(1) + frenchTranslatorNotifications.assert.hasSize(1) + czechTranslatorNotifications.assert.hasSize(1) + germanTranslatorNotifications.assert.hasSize(1) + frenchCzechTranslatorNotifications.assert.hasSize(1) + bobNotifications.assert.hasSize(1) + aliceNotifications.assert.hasSize(0) + + ensureNoUserNotificationDispatch() + } + + @Test + fun `it does not dispatch notifications to people without the permission to see the change`() { + performAuthMultipart( + url = "/v2/projects/${testData.project1.id}/keys/${testData.keyProject1.id}/screenshots", + files = + listOf( + MockMultipartFile( + "screenshot", + "originalShot.png", + "image/png", + generateImage(100, 100).inputStream.readAllBytes(), + ), + ), + ).andIsCreated + + waitUntilUserNotificationDispatch(6) + val acmeChiefNotifications = userNotificationRepository.findAllByRecipient(testData.orgAdmin) + val projectManagerNotifications = userNotificationRepository.findAllByRecipient(testData.projectManager) + val frenchTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.frenchTranslator) + val czechTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.czechTranslator) + val germanTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.germanTranslator) + val frenchCzechTranslatorNotifications = + userNotificationRepository.findAllByRecipient(testData.frenchCzechTranslator) + val bobNotifications = userNotificationRepository.findAllByRecipient(testData.bob) + + acmeChiefNotifications.assert.hasSize(1) + projectManagerNotifications.assert.hasSize(1) + frenchTranslatorNotifications.assert.hasSize(1) + czechTranslatorNotifications.assert.hasSize(1) + germanTranslatorNotifications.assert.hasSize(1) + frenchCzechTranslatorNotifications.assert.hasSize(1) + bobNotifications.assert.hasSize(0) + + ensureNoUserNotificationDispatch() + } + + @Test + fun `it does not dispatch notifications to people without the permission to see the target language`() { + performAuthPut( + url = "/v2/projects/${testData.project1.id}/translations", + content = + SetTranslationsWithKeyDto( + key = testData.keyProject1.name, + translations = mapOf("fr" to "Superb French translation!"), + ), + ).andIsOk + + waitUntilUserNotificationDispatch(5) + val acmeChiefNotifications = userNotificationRepository.findAllByRecipient(testData.orgAdmin) + val projectManagerNotifications = userNotificationRepository.findAllByRecipient(testData.projectManager) + val frenchTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.frenchTranslator) + val czechTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.czechTranslator) + val germanTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.germanTranslator) + val frenchCzechTranslatorNotifications = + userNotificationRepository.findAllByRecipient(testData.frenchCzechTranslator) + val bobNotifications = userNotificationRepository.findAllByRecipient(testData.bob) + + acmeChiefNotifications.assert.hasSize(1) + projectManagerNotifications.assert.hasSize(1) + frenchTranslatorNotifications.assert.hasSize(1) + czechTranslatorNotifications.assert.hasSize(0) + germanTranslatorNotifications.assert.hasSize(0) + frenchCzechTranslatorNotifications.assert.hasSize(1) + bobNotifications.assert.hasSize(1) + + ensureNoUserNotificationDispatch() + } + + @Test + fun `it does dispatch notifications with trimmed data to people who can only see part of the changes`() { + performAuthPut( + url = "/v2/projects/${testData.project1.id}/translations", + content = + SetTranslationsWithKeyDto( + key = testData.keyProject1.name, + translations = mapOf("fr" to "Superb French translation!", "cs" to "Superb Czech translation!"), + ), + ).andIsOk + + waitUntilUserNotificationDispatch(6) + val acmeChiefNotifications = userNotificationRepository.findAllByRecipient(testData.orgAdmin) + val projectManagerNotifications = userNotificationRepository.findAllByRecipient(testData.projectManager) + val frenchTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.frenchTranslator) + val czechTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.czechTranslator) + val germanTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.germanTranslator) + val frenchCzechTranslatorNotifications = + userNotificationRepository.findAllByRecipient(testData.frenchCzechTranslator) + val bobNotifications = userNotificationRepository.findAllByRecipient(testData.bob) + + acmeChiefNotifications.assert.hasSize(1) + projectManagerNotifications.assert.hasSize(1) + frenchTranslatorNotifications.assert.hasSize(1) + czechTranslatorNotifications.assert.hasSize(1) + germanTranslatorNotifications.assert.hasSize(0) + frenchCzechTranslatorNotifications.assert.hasSize(1) + bobNotifications.assert.hasSize(1) + + acmeChiefNotifications[0].modifiedEntities.assert.hasSize(2) + projectManagerNotifications[0].modifiedEntities.assert.hasSize(2) + frenchTranslatorNotifications[0].modifiedEntities.assert.hasSize(1) + czechTranslatorNotifications[0].modifiedEntities.assert.hasSize(1) + frenchCzechTranslatorNotifications[0].modifiedEntities.assert.hasSize(2) + bobNotifications[0].modifiedEntities.assert.hasSize(2) + + frenchTranslatorNotifications[0].modifiedEntities.first() + .entityId.assert.isEqualTo(testData.key1FrTranslation.id) + + czechTranslatorNotifications[0].modifiedEntities.first() + .entityId.assert.isEqualTo(testData.key1CzTranslation.id) + + ensureNoUserNotificationDispatch() + } + + @Test + fun `it does not dispatch modifications to the responsible user`() { + loginAsUser(testData.projectManager) + performAuthPost( + "/v2/projects/${testData.project1.id}/keys/create", + CreateKeyDto(name = "test-key"), + ).andIsCreated + + waitUntilUserNotificationDispatch(6) + val adminNotifications = userNotificationRepository.findAllByRecipient(testData.admin) + val acmeChiefNotifications = userNotificationRepository.findAllByRecipient(testData.orgAdmin) + val projectManagerNotifications = userNotificationRepository.findAllByRecipient(testData.projectManager) + val frenchTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.frenchTranslator) + val czechTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.czechTranslator) + val germanTranslatorNotifications = userNotificationRepository.findAllByRecipient(testData.germanTranslator) + val frenchCzechTranslatorNotifications = + userNotificationRepository.findAllByRecipient(testData.frenchCzechTranslator) + val bobNotifications = userNotificationRepository.findAllByRecipient(testData.bob) + val aliceNotifications = userNotificationRepository.findAllByRecipient(testData.alice) + + adminNotifications.assert.hasSize(0) + acmeChiefNotifications.assert.hasSize(1) + projectManagerNotifications.assert.hasSize(0) + frenchTranslatorNotifications.assert.hasSize(1) + czechTranslatorNotifications.assert.hasSize(1) + germanTranslatorNotifications.assert.hasSize(1) + frenchCzechTranslatorNotifications.assert.hasSize(1) + bobNotifications.assert.hasSize(1) + aliceNotifications.assert.hasSize(0) + + ensureNoUserNotificationDispatch() + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/notifications/UserNotificationSubscriptionTest.kt b/backend/app/src/test/kotlin/io/tolgee/notifications/UserNotificationSubscriptionTest.kt new file mode 100644 index 0000000000..31c18c34e2 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/notifications/UserNotificationSubscriptionTest.kt @@ -0,0 +1,68 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications + +import io.tolgee.development.testDataBuilder.data.NotificationSubscriptionTestData +import io.tolgee.dtos.request.key.CreateKeyDto +import io.tolgee.fixtures.andIsCreated +import io.tolgee.testing.assert +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class UserNotificationSubscriptionTest : AbstractNotificationTest() { + lateinit var testData: NotificationSubscriptionTestData + + @BeforeEach + override fun setupTests() { + testData = NotificationSubscriptionTestData() + testDataService.saveTestData(testData.root) + + super.setupTests() + } + + @Test + fun `it respects global notification subscription settings`() { + performAuthPost( + "/v2/projects/${testData.project1.id}/keys/create", + CreateKeyDto(name = "test-key"), + ).andIsCreated + + waitUntilUserNotificationDispatch(1) + val notifications1 = userNotificationRepository.findAllByRecipient(testData.user1) + val notifications2 = userNotificationRepository.findAllByRecipient(testData.user2) + + notifications1.assert.hasSize(1) + notifications2.assert.hasSize(0) + ensureNoUserNotificationDispatch() + } + + @Test + fun `it respects project-level notification subscription settings`() { + performAuthPost( + "/v2/projects/${testData.project2.id}/keys/create", + CreateKeyDto(name = "test-key"), + ).andIsCreated + + waitUntilUserNotificationDispatch(1) + val notifications1 = userNotificationRepository.findAllByRecipient(testData.user1) + val notifications2 = userNotificationRepository.findAllByRecipient(testData.user2) + + notifications1.assert.hasSize(0) + notifications2.assert.hasSize(1) + ensureNoUserNotificationDispatch() + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/notifications/UserNotificationTranslationTest.kt b/backend/app/src/test/kotlin/io/tolgee/notifications/UserNotificationTranslationTest.kt new file mode 100644 index 0000000000..d7a6b222e2 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/notifications/UserNotificationTranslationTest.kt @@ -0,0 +1,112 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications + +import io.tolgee.development.testDataBuilder.data.NotificationsTestData +import io.tolgee.dtos.request.key.CreateKeyDto +import io.tolgee.dtos.request.translation.SetTranslationsWithKeyDto +import io.tolgee.fixtures.andIsCreated +import io.tolgee.fixtures.andIsOk +import io.tolgee.testing.assert +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class UserNotificationTranslationTest : AbstractNotificationTest() { + lateinit var testData: NotificationsTestData + + @BeforeEach + override fun setupTests() { + testData = NotificationsTestData() + testDataService.saveTestData(testData.root) + + super.setupTests() + } + + @Test + fun `it does not dispatch the same type of notification for source strings and translated strings`() { + performAuthPut( + url = "/v2/projects/${testData.calmProject.id}/translations", + content = + SetTranslationsWithKeyDto( + key = testData.keyCalmProject.name, + translations = mapOf("en" to "Superb English translation!"), + ), + ).andIsOk + + waitUntilUserNotificationDispatch() + + userNotificationRepository.findAllByRecipient(testData.alice).assert + .satisfiesOnlyOnce { it.type.assert.isEqualTo(NotificationType.ACTIVITY_SOURCE_STRINGS_UPDATED) } + .noneSatisfy { it.type.assert.isEqualTo(NotificationType.ACTIVITY_TRANSLATIONS_UPDATED) } + + performAuthPut( + url = "/v2/projects/${testData.calmProject.id}/translations", + content = + SetTranslationsWithKeyDto( + key = testData.keyCalmProject.name, + translations = mapOf("fr" to "Superb French translation!"), + ), + ).andIsOk + + waitUntilUserNotificationDispatch() + + userNotificationRepository.findAllByRecipient(testData.alice).assert + .satisfiesOnlyOnce { it.type.assert.isEqualTo(NotificationType.ACTIVITY_SOURCE_STRINGS_UPDATED) } + .satisfiesOnlyOnce { it.type.assert.isEqualTo(NotificationType.ACTIVITY_TRANSLATIONS_UPDATED) } + } + + @Test + fun `it does debounce key creation and setting strings as a single notification`() { + performAuthPost( + "/v2/projects/${testData.calmProject.id}/keys/create", + CreateKeyDto(name = "test-key"), + ).andIsCreated + + waitUntilUserNotificationDispatch() + + performAuthPut( + url = "/v2/projects/${testData.calmProject.id}/translations", + content = + SetTranslationsWithKeyDto( + key = "test-key", + translations = mapOf("en" to "Superb English translation!", "fr" to "Superb French translation!"), + ), + ).andIsOk + + waitUntilUserNotificationDispatch() + + userNotificationRepository.findAllByRecipient(testData.alice).assert.hasSize(1) + } + + @Test + fun `it does not dispatch outdated notifications if it was not done manually`() { + performAuthPut( + url = "/v2/projects/${testData.calmProject.id}/translations", + content = + SetTranslationsWithKeyDto( + key = testData.keyCalmProject.name, + translations = mapOf("en" to "Superb English translation!"), + ), + ).andIsOk + + waitUntilUserNotificationDispatch() + + userNotificationRepository.findAllByRecipient(testData.alice).assert + .noneMatch { it.type == NotificationType.ACTIVITY_TRANSLATION_OUTDATED } + .noneMatch { it.type == NotificationType.ACTIVITY_TRANSLATIONS_UPDATED } + } +} diff --git a/backend/app/src/test/resources/application.yaml b/backend/app/src/test/resources/application.yaml index c67fbea1e5..110546e591 100644 --- a/backend/app/src/test/resources/application.yaml +++ b/backend/app/src/test/resources/application.yaml @@ -40,6 +40,8 @@ spring: enabled: false datasource: maximum-pool-size: 100 + jooq: + sql-dialect: postgres tolgee: slack: token: fakeToken @@ -95,3 +97,5 @@ logging: io.tolgee.component.reporting.BusinessEventPublisher: DEBUG io.tolgee.ExceptionHandlers: DEBUG io.tolgee.component.reporting.ReportingService: DEBUG + org.jooq.tools.LoggerListener: DEBUG + org.jooq.Constants: off diff --git a/backend/data/build.gradle b/backend/data/build.gradle index dd27da2685..044a722555 100644 --- a/backend/data/build.gradle +++ b/backend/data/build.gradle @@ -90,6 +90,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation "org.springframework.boot:spring-boot-starter-validation" + implementation "org.springframework.boot:spring-boot-starter-hateoas" implementation("org.springframework.data:spring-data-envers") implementation("org.springframework.boot:spring-boot-starter-security") implementation 'org.springframework.boot:spring-boot-starter-mail' @@ -98,6 +99,7 @@ dependencies { implementation "org.springframework.boot:spring-boot-configuration-processor" implementation "org.springframework.boot:spring-boot-starter-batch" implementation "org.springframework.boot:spring-boot-starter-websocket" + implementation libs.springJooq /** * DB @@ -150,7 +152,7 @@ dependencies { * MISC */ implementation libs.commonsCodec - implementation group: 'org.apache.commons', name: 'commons-text', version: '1.10.0' + implementation libs.commonsText implementation libs.icu4j implementation libs.jjwtApi implementation libs.jjwtImpl diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/ActivityAdditionalDescriber.kt b/backend/data/src/main/kotlin/io/tolgee/activity/ActivityAdditionalDescriber.kt new file mode 100644 index 0000000000..6a128061bb --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/ActivityAdditionalDescriber.kt @@ -0,0 +1,10 @@ +package io.tolgee.activity + +import io.tolgee.model.activity.ActivityRevision + +interface ActivityAdditionalDescriber { + fun describe( + activityRevision: ActivityRevision, + modifiedEntities: ModifiedEntitiesType, + ) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/ActivityHolder.kt b/backend/data/src/main/kotlin/io/tolgee/activity/ActivityHolder.kt index e6860cf2ca..4416030fb7 100644 --- a/backend/data/src/main/kotlin/io/tolgee/activity/ActivityHolder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/activity/ActivityHolder.kt @@ -8,6 +8,7 @@ import io.tolgee.model.activity.ActivityModifiedEntity import io.tolgee.model.activity.ActivityRevision import jakarta.annotation.PreDestroy import org.springframework.context.ApplicationContext +import java.util.IdentityHashMap import kotlin.reflect.KClass open class ActivityHolder(val applicationContext: ApplicationContext) { @@ -69,4 +70,4 @@ open class ActivityHolder(val applicationContext: ApplicationContext) { } } -typealias ModifiedEntitiesType = MutableMap, MutableMap> +typealias ModifiedEntitiesType = MutableMap, IdentityHashMap> diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/ActivityService.kt b/backend/data/src/main/kotlin/io/tolgee/activity/ActivityService.kt index 596019dca5..6611bae64c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/activity/ActivityService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/activity/ActivityService.kt @@ -3,6 +3,7 @@ package io.tolgee.activity import com.fasterxml.jackson.databind.ObjectMapper import io.tolgee.activity.data.ActivityType import io.tolgee.activity.data.RevisionType +import io.tolgee.activity.groups.ActivityGroupService import io.tolgee.activity.projectActivity.ModificationsByRevisionsProvider import io.tolgee.activity.projectActivity.ProjectActivityViewByPageableProvider import io.tolgee.activity.projectActivity.ProjectActivityViewByRevisionProvider @@ -31,6 +32,8 @@ class ActivityService( private val activityModifiedEntityRepository: ActivityModifiedEntityRepository, private val objectMapper: ObjectMapper, private val jdbcTemplate: JdbcTemplate, + private val activityGroupService: ActivityGroupService, + private val describers: List, ) : Logging { @Transactional fun storeActivityData( @@ -40,40 +43,56 @@ class ActivityService( // let's keep the persistent context small entityManager.flushAndClear() - val mergedActivityRevision = persistActivityRevision(activityRevision) + runAdditionalDescribers(activityRevision, modifiedEntities) + val mergedActivityRevision = persistActivityRevision(activityRevision) persistedDescribingRelations(mergedActivityRevision) mergedActivityRevision.modifiedEntities = persistModifiedEntities(modifiedEntities) + + activityGroupService.addToGroup(activityRevision, modifiedEntities) + applicationContext.publishEvent(OnProjectActivityStoredEvent(this, mergedActivityRevision)) } + private fun runAdditionalDescribers( + activityRevision: ActivityRevision, + modifiedEntities: ModifiedEntitiesType, + ) { + describers.forEach { + it.describe(activityRevision, modifiedEntities) + } + } + private fun persistModifiedEntities(modifiedEntities: ModifiedEntitiesType): MutableList { - val list = modifiedEntities.values.flatMap { it.values }.toMutableList() + val list = modifiedEntities.values.flatMap { it.entries }.toMutableList() jdbcTemplate.batchUpdate( "INSERT INTO activity_modified_entity " + "(entity_class, entity_id, describing_data, " + - "describing_relations, modifications, revision_type, activity_revision_id) " + - "VALUES (?, ?, ?, ?, ?, ?, ?)", + "describing_relations, modifications, revision_type, activity_revision_id, additional_description) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", list, 100, - ) { ps, entity -> - ps.setString(1, entity.entityClass) - ps.setLong(2, entity.entityId) - ps.setObject(3, getJsonbObject(entity.describingData)) - ps.setObject(4, getJsonbObject(entity.describingRelations)) - ps.setObject(5, getJsonbObject(entity.modifications)) - ps.setInt(6, RevisionType.values().indexOf(entity.revisionType)) - ps.setLong(7, entity.activityRevision.id) + ) { ps, (entityInstance, modifiedEntity) -> + // the entity id can be null in some cases (probably when the id it's not allocated in batch) + val entityId = if (modifiedEntity.entityId == 0L) entityInstance.id else modifiedEntity.entityId + ps.setString(1, modifiedEntity.entityClass) + ps.setLong(2, entityId) + ps.setObject(3, getJsonbObject(modifiedEntity.describingData)) + ps.setObject(4, getJsonbObject(modifiedEntity.describingRelations)) + ps.setObject(5, getJsonbObject(modifiedEntity.modifications)) + ps.setInt(6, RevisionType.entries.indexOf(modifiedEntity.revisionType)) + ps.setLong(7, modifiedEntity.activityRevision.id) + ps.setObject(8, getJsonbObject(modifiedEntity.additionalDescription)) } - return list + return list.map { it.value }.toMutableList() } private fun persistedDescribingRelations(activityRevision: ActivityRevision) { jdbcTemplate.batchUpdate( "INSERT INTO activity_describing_entity " + - "(entity_class, entity_id, data, describing_relations, activity_revision_id) " + - "VALUES (?, ?, ?, ?, ?)", + "(entity_class, entity_id, data, describing_relations, activity_revision_id, additional_description) " + + "VALUES (?, ?, ?, ?, ?, ?)", activityRevision.describingRelations, 100, ) { ps, entity -> @@ -82,6 +101,7 @@ class ActivityService( ps.setObject(3, getJsonbObject(entity.data)) ps.setObject(4, getJsonbObject(entity.describingRelations)) ps.setLong(5, activityRevision.id) + ps.setObject(6, getJsonbObject(entity.additionalDescription)) } } @@ -103,7 +123,7 @@ class ActivityService( } @Transactional - fun findProjectActivity( + fun getProjectActivity( projectId: Long, pageable: Pageable, ): Page { @@ -115,15 +135,7 @@ class ActivityService( } @Transactional - fun findProjectActivity(revisionId: Long): ProjectActivityView? { - return ProjectActivityViewByRevisionProvider( - applicationContext = applicationContext, - revisionId = revisionId, - ).get() - } - - @Transactional - fun findProjectActivity( + fun getProjectActivity( projectId: Long, revisionId: Long, ): ProjectActivityView? { diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/additionalDescribers/KeyBaseTranslationDescriber.kt b/backend/data/src/main/kotlin/io/tolgee/activity/additionalDescribers/KeyBaseTranslationDescriber.kt new file mode 100644 index 0000000000..b3523c9cd2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/additionalDescribers/KeyBaseTranslationDescriber.kt @@ -0,0 +1,156 @@ +package io.tolgee.activity.additionalDescribers + +import io.tolgee.activity.ActivityAdditionalDescriber +import io.tolgee.activity.ModifiedEntitiesType +import io.tolgee.activity.data.RevisionType +import io.tolgee.model.activity.ActivityDescribingEntity +import io.tolgee.model.activity.ActivityEntityWithDescription +import io.tolgee.model.activity.ActivityModifiedEntity +import io.tolgee.model.activity.ActivityRevision +import io.tolgee.model.key.Key +import io.tolgee.model.translation.Translation +import jakarta.persistence.EntityManager +import org.springframework.stereotype.Component + +@Component +class KeyBaseTranslationDescriber( + private val entityManager: EntityManager, +) : ActivityAdditionalDescriber { + companion object { + const val FIELD_NAME: String = "baseTranslation" + } + + override fun describe( + activityRevision: ActivityRevision, + modifiedEntities: ModifiedEntitiesType, + ) { + val relevantTranslationModifiedEntities = getRelevantModifiedEntities(activityRevision, modifiedEntities) + val relevantTranslationDescribingEntities = getRelevantDescribingEntities(activityRevision) + val toDescribe = getToDescribe(activityRevision, modifiedEntities) + val newKeyIds = + modifiedEntities[Key::class]?.mapNotNull { + if (it.value.revisionType == RevisionType.ADD) it.key.id else null + }?.toSet() ?: emptySet() + + toDescribe.removeIf { (id, entity) -> + // if entity is modified in current revision, we take it from there + relevantTranslationModifiedEntities[id]?.let { + entity.describe(getFromModifiedEntity(it)) + return@removeIf true + } + + // if entity is described in current revision, we take it from there + relevantTranslationDescribingEntities[id]?.let { + entity.describe(getFromDescribingEntity(it)) + return@removeIf true + } + + // if base value is not set for new key it doesn't make sense to search for it in the database + if (newKeyIds.contains(id)) { + entity.describe(null) + return@removeIf true + } + + false + } + + // other entities are taken from the database + describeRest(activityRevision, toDescribe) + } + + private fun ActivityEntityWithDescription.describe(baseText: String?) { + val additionalDescription = initAdditionalDescription() + additionalDescription[FIELD_NAME] = BaseTranslationDescription(baseText) + } + + private fun getToDescribe( + activityRevision: ActivityRevision, + allModifiedEntities: ModifiedEntitiesType, + ): MutableList> { + val describingRelations = + activityRevision.describingRelations.mapNotNull { + if (it.entityClass != "Key") return@mapNotNull null + it.entityId to it + } + + val modifiedEntities = allModifiedEntities[Key::class]?.map { it.key.id to it.value } ?: emptyList() + + return (describingRelations + modifiedEntities).toMutableList() + } + + private fun describeRest( + activityRevision: ActivityRevision, + toDescribe: MutableList>, + ) { + if (toDescribe.isEmpty()) { + return + } + + val keyIds = toDescribe.map { it.first } + val result = + entityManager.createQuery( + "select t.key.id, t.text from Translation t where t.key.id in :keyIds and t.language.id = :languageId", + Array::class.java, + ) + .setParameter("keyIds", keyIds.toSet()) + .setParameter("languageId", activityRevision.baseLanguageId) + .resultList + + return result.forEach { + val keyId = it[0] as Long + val text = it[1] as? String + toDescribe + .filter { (toDescribeId) -> toDescribeId == keyId } + .forEach { (_, entity) -> + entity.describe(text) + } + } + } + + private fun getFromDescribingEntity(it: ActivityDescribingEntity): String? { + return it.data["text"] as? String + } + + private fun getFromModifiedEntity(entity: ActivityModifiedEntity): String? { + val text = entity.modifications["text"]?.new ?: entity.describingData?.get("text") + return text as? String + } + + private fun getRelevantModifiedEntities( + activityRevision: ActivityRevision, + modifiedEntities: ModifiedEntitiesType, + ): Map { + val baseLanguageId = activityRevision.baseLanguageId + + return modifiedEntities[Translation::class]?.values?.mapNotNull { + val languageId = it.languageId ?: return@mapNotNull null + if (languageId != baseLanguageId) return@mapNotNull null + val keyId = it.keyId ?: return@mapNotNull null + keyId to it + }?.toMap() ?: emptyMap() + } + + private fun getRelevantDescribingEntities(activityRevision: ActivityRevision): Map { + val baseLanguageId = activityRevision.baseLanguageId + + return activityRevision.describingRelations.mapNotNull { + if (it.entityClass != "Translation") return@mapNotNull null + val languageId = it.languageId ?: return@mapNotNull null + if (languageId != baseLanguageId) return@mapNotNull null + val keyId = it.keyId ?: return@mapNotNull null + keyId to it + }.toMap() + } + + val ActivityEntityWithDescription.languageId: Long? + get() = + this.describingRelations?.get(Translation::language.name)?.entityId + + val ActivityEntityWithDescription.keyId: Long? + get() = + this.describingRelations?.get(Translation::key.name)?.entityId + + data class BaseTranslationDescription( + val text: String?, + ) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/data/ActivityType.kt b/backend/data/src/main/kotlin/io/tolgee/activity/data/ActivityType.kt index 849d17fdb3..a11c60cc7d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/activity/data/ActivityType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/activity/data/ActivityType.kt @@ -4,6 +4,9 @@ import io.tolgee.activity.PublicParamsProvider import io.tolgee.batch.BatchActivityParamsProvider import io.tolgee.model.EntityWithId import io.tolgee.model.Language +import io.tolgee.model.key.Key +import io.tolgee.model.key.KeyMeta +import io.tolgee.model.translation.Translation import kotlin.reflect.KClass enum class ActivityType( @@ -13,14 +16,45 @@ enum class ActivityType( val hideInList: Boolean = false, /** * If true, the activity will be saved even if it does - * not contain any changes in fields market for activity logging + * not contain any changes in fields marked for activity logging */ val saveWithoutModification: Boolean = false, + val typeDefinitions: Map, EntityModificationTypeDefinition<*>>? = null, ) { UNKNOWN, - SET_TRANSLATION_STATE, - SET_TRANSLATIONS, - DISMISS_AUTO_TRANSLATED_STATE, + SET_TRANSLATION_STATE( + typeDefinitions = + mapOf( + Translation::class to + EntityModificationTypeDefinition( + creation = false, + modificationProps = arrayOf(Translation::state, Translation::outdated, Translation::mtProvider), + deletion = false, + ), + ), + ), + SET_TRANSLATIONS( + typeDefinitions = + mapOf( + Translation::class to + EntityModificationTypeDefinition( + creation = true, + modificationProps = arrayOf(), + deletion = true, + ), + ), + ), + DISMISS_AUTO_TRANSLATED_STATE( + typeDefinitions = + mapOf( + Translation::class to + EntityModificationTypeDefinition( + creation = false, + modificationProps = arrayOf(Translation::outdated, Translation::mtProvider), + deletion = false, + ), + ), + ), SET_OUTDATED_FLAG, TRANSLATION_COMMENT_ADD, TRANSLATION_COMMENT_DELETE, @@ -31,7 +65,23 @@ enum class ActivityType( KEY_TAGS_EDIT, KEY_NAME_EDIT, KEY_DELETE(true), - CREATE_KEY, + CREATE_KEY( + typeDefinitions = + mapOf( + Key::class to + EntityModificationTypeDefinition( + creation = true, + modificationProps = null, + deletion = false, + ), + KeyMeta::class to + EntityModificationTypeDefinition( + creation = true, + modificationProps = null, + deletion = false, + ), + ), + ), COMPLEX_EDIT, IMPORT(true), CREATE_LANGUAGE, diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/data/EntityModificationTypeDefinition.kt b/backend/data/src/main/kotlin/io/tolgee/activity/data/EntityModificationTypeDefinition.kt new file mode 100644 index 0000000000..33f59356bb --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/data/EntityModificationTypeDefinition.kt @@ -0,0 +1,13 @@ +package io.tolgee.activity.data + +import io.tolgee.model.EntityWithId +import kotlin.reflect.KProperty1 + +class EntityModificationTypeDefinition( + val creation: Boolean = false, + /** + * If null then all props can be modified + */ + val modificationProps: Array>? = emptyArray(), + val deletion: Boolean = false, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/data/PropertyModification.kt b/backend/data/src/main/kotlin/io/tolgee/activity/data/PropertyModification.kt index 8cb7111ee6..b9d29cdeea 100644 --- a/backend/data/src/main/kotlin/io/tolgee/activity/data/PropertyModification.kt +++ b/backend/data/src/main/kotlin/io/tolgee/activity/data/PropertyModification.kt @@ -1,6 +1,6 @@ package io.tolgee.activity.data -data class PropertyModification( - val old: Any?, - val new: Any?, +data class PropertyModification( + val old: T?, + val new: T?, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGroupDto.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGroupDto.kt new file mode 100644 index 0000000000..de19fcac4f --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGroupDto.kt @@ -0,0 +1,11 @@ +package io.tolgee.activity.groups + +import java.util.* + +data class ActivityGroupDto( + val id: Long, + val activityGroupType: ActivityGroupType, + val latestTimestamp: Date, + val earliestTimestamp: Date, + val matchingString: String? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGroupService.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGroupService.kt new file mode 100644 index 0000000000..0fc74abd51 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGroupService.kt @@ -0,0 +1,145 @@ +package io.tolgee.activity.groups + +import io.tolgee.activity.ModifiedEntitiesType +import io.tolgee.component.CurrentDateProvider +import io.tolgee.dtos.queryResults.ActivityGroupView +import io.tolgee.dtos.request.ActivityGroupFilters +import io.tolgee.model.activity.ActivityGroup +import io.tolgee.model.activity.ActivityRevision +import io.tolgee.repository.activity.ActivityGroupRepository +import org.springframework.context.ApplicationContext +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.* + +@Service +class ActivityGroupService( + private val applicationContext: ApplicationContext, + private val activityGroupRepository: ActivityGroupRepository, + private val currentDateProvider: CurrentDateProvider, +) { + fun addToGroup( + activityRevision: ActivityRevision, + modifiedEntities: ModifiedEntitiesType, + ) { + ActivityGrouper(activityRevision, modifiedEntities, applicationContext).addToGroup() + } + + fun getOrCreateCurrentActivityGroupDto( + type: ActivityGroupType, + matchingStrings: Set, + projectId: Long?, + authorId: Long?, + ): Map { + return matchingStrings.associateWith { matchingString -> + val existing = findSuitableExistingSuitableGroupDto(type, matchingString, projectId, authorId) + val group = existing ?: createActivityGroupDto(type, matchingString, projectId, authorId) + group + } + } + + private fun createActivityGroupDto( + type: ActivityGroupType, + matchingString: String?, + projectId: Long?, + authorId: Long?, + ): ActivityGroupDto { + val entity = createActivityGroup(type, matchingString, projectId, authorId) + return ActivityGroupDto( + entity.id, + entity.type, + currentDateProvider.date, + currentDateProvider.date, + matchingString = entity.matchingString, + ) + } + + private fun createActivityGroup( + type: ActivityGroupType, + matchingString: String?, + projectId: Long?, + authorId: Long?, + ): ActivityGroup { + return ActivityGroup( + type = type, + ).also { + it.authorId = authorId + it.projectId = projectId + activityGroupRepository.saveAndFlush(it) + it.matchingString = matchingString + } + } + + private fun findSuitableExistingSuitableGroupDto( + type: ActivityGroupType, + matchingString: String?, + projectId: Long?, + authorId: Long?, + ): ActivityGroupDto? { + val latest = findLatest(type, matchingString, authorId, projectId) ?: return null + if (latest.isTooOld || latest.lastActivityTooEarly) { + return null + } + return latest + } + + private fun findLatest( + type: ActivityGroupType, + matchingString: String?, + authorId: Long?, + projectId: Long?, + ): ActivityGroupDto? { + val result = + activityGroupRepository.findLatest( + groupTypeName = type.name, + matchingString = matchingString, + authorId = authorId, + projectId = projectId, + ) + + if (result.isEmpty()) { + return null + } + + val single = result.single() + + return single.mapToGroupDto() + } + + private fun Array.mapToGroupDto(): ActivityGroupDto { + return ActivityGroupDto( + this[0] as Long, + ActivityGroupType.valueOf(this[1] as String), + // if the group is empty we can just consider it as created now + this[3] as Date? ?: currentDateProvider.date, + this[4] as Date? ?: currentDateProvider.date, + matchingString = this[2] as String?, + ) + } + + @Transactional + fun getProjectActivityGroups( + projectId: Long, + pageable: Pageable, + activityGroupFilters: ActivityGroupFilters, + ): PageImpl { + return ActivityGroupsProvider(projectId, pageable, activityGroupFilters, applicationContext).get() + } + + private val ActivityGroupDto.isTooOld: Boolean + get() { + return this.earliestTimestamp.time + GROUP_MAX_AGE < currentDateProvider.date.time + } + + private val ActivityGroupDto.lastActivityTooEarly: Boolean + get() { + return latestTimestamp.time + GROUP_MAX_LAST_ACTIVITY_AGE < currentDateProvider.date.time + } + + companion object { + const val GROUP_MAX_AGE = 1000 * 60 * 60 * 24 + const val GROUP_MAX_LAST_ACTIVITY_AGE = 1000 * 60 * 60 * 2 + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGroupType.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGroupType.kt new file mode 100644 index 0000000000..5160bcb4cf --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGroupType.kt @@ -0,0 +1,490 @@ +package io.tolgee.activity.groups + +import io.tolgee.activity.data.ActivityType +import io.tolgee.activity.data.RevisionType +import io.tolgee.activity.groups.matchers.ActivityGroupValueMatcher.Companion.eq +import io.tolgee.activity.groups.matchers.ActivityGroupValueMatcher.Companion.modification +import io.tolgee.activity.groups.matchers.ActivityGroupValueMatcher.Companion.notNull +import io.tolgee.activity.groups.matchers.modifiedEntity.DefaultMatcher +import io.tolgee.activity.groups.matchers.modifiedEntity.MatchingStringProvider +import io.tolgee.activity.groups.matchers.modifiedEntity.ModifiedEntityMatcher +import io.tolgee.activity.groups.matchers.modifiedEntity.SetTranslationMatchingStringProvider +import io.tolgee.activity.groups.matchers.modifiedEntity.TranslationMatcher +import io.tolgee.activity.groups.viewProviders.createKey.CreateKeyGroupModelProvider +import io.tolgee.activity.groups.viewProviders.createProject.CreateProjectGroupModelProvider +import io.tolgee.activity.groups.viewProviders.setTranslations.SetTranslationsGroupModelProvider +import io.tolgee.model.Language +import io.tolgee.model.Project +import io.tolgee.model.Screenshot +import io.tolgee.model.contentDelivery.ContentDeliveryConfig +import io.tolgee.model.contentDelivery.ContentStorage +import io.tolgee.model.enums.TranslationState +import io.tolgee.model.key.Key +import io.tolgee.model.key.KeyMeta +import io.tolgee.model.key.Namespace +import io.tolgee.model.key.Tag +import io.tolgee.model.key.screenshotReference.KeyScreenshotReference +import io.tolgee.model.translation.Translation +import io.tolgee.model.translation.TranslationComment +import io.tolgee.model.webhook.WebhookConfig +import kotlin.reflect.KClass + +enum class ActivityGroupType( + val sourceActivityTypes: List, + val modelProviderFactoryClass: KClass>? = null, + val matcher: ModifiedEntityMatcher? = null, + val matchingStringProvider: MatchingStringProvider? = null, + /** + * Even if we are working with same revisions order can be unnatural in some cases. + * e.g. when user creates a key and then sets translations for it. It results in multiple groups. + * We need to force the order, so key creation is always before translation set + */ + val orderAfter: ActivityGroupType? = null, +) { + CREATE_KEY( + listOf(ActivityType.CREATE_KEY), + matcher = + DefaultMatcher( + entityClass = Key::class, + revisionTypes = listOf(RevisionType.ADD), + ).or( + DefaultMatcher( + entityClass = KeyMeta::class, + revisionTypes = listOf(RevisionType.ADD), + ), + ).or( + TranslationMatcher(TranslationMatcher.Type.BASE), + ), + modelProviderFactoryClass = CreateKeyGroupModelProvider::class, + ), + + EDIT_KEY_NAME( + listOf(ActivityType.KEY_NAME_EDIT, ActivityType.COMPLEX_EDIT), + matcher = + DefaultMatcher( + entityClass = Key::class, + revisionTypes = listOf(RevisionType.MOD), + modificationProps = listOf(Key::name, Key::namespace), + ).or( + DefaultMatcher( + entityClass = Namespace::class, + revisionTypes = listOf(RevisionType.ADD, RevisionType.DEL), + ), + ), + ), + + DELETE_KEY( + listOf(ActivityType.KEY_DELETE), + matcher = + DefaultMatcher( + entityClass = Key::class, + revisionTypes = listOf(RevisionType.DEL), + ), + ), + + SET_TRANSLATION_STATE( + listOf(ActivityType.SET_TRANSLATION_STATE, ActivityType.COMPLEX_EDIT), + matcher = + DefaultMatcher( + entityClass = Translation::class, + revisionTypes = listOf(RevisionType.MOD, RevisionType.ADD), + modificationProps = listOf(Translation::state, Translation::outdated, Translation::mtProvider), + deniedValues = + mapOf( + Translation::state to TranslationState.REVIEWED, + Translation::text to modification(eq(null) to notNull()), + ), + ), + ), + REVIEW( + listOf(ActivityType.SET_TRANSLATION_STATE, ActivityType.COMPLEX_EDIT), + matcher = + DefaultMatcher( + entityClass = Translation::class, + revisionTypes = listOf(RevisionType.MOD, RevisionType.ADD), + modificationProps = listOf(Translation::state, Translation::outdated, Translation::mtProvider), + allowedValues = mapOf(Translation::state to TranslationState.REVIEWED), + ), + ), + + SET_BASE_TRANSLATION( + listOf(ActivityType.SET_TRANSLATIONS, ActivityType.COMPLEX_EDIT), + matcher = + TranslationMatcher(TranslationMatcher.Type.BASE), + ), + + SET_TRANSLATIONS( + listOf(ActivityType.SET_TRANSLATIONS, ActivityType.COMPLEX_EDIT, ActivityType.CREATE_KEY), + matcher = + TranslationMatcher(TranslationMatcher.Type.NON_BASE), + matchingStringProvider = SetTranslationMatchingStringProvider(), + modelProviderFactoryClass = SetTranslationsGroupModelProvider::class, + orderAfter = CREATE_KEY, + ), + + DISMISS_AUTO_TRANSLATED_STATE( + listOf(ActivityType.DISMISS_AUTO_TRANSLATED_STATE), + matcher = + DefaultMatcher( + entityClass = Translation::class, + revisionTypes = listOf(RevisionType.MOD), + modificationProps = listOf(Translation::auto, Translation::mtProvider), + allowedValues = mapOf(Translation::mtProvider to null, Translation::auto to false), + ), + ), + + SET_OUTDATED_FLAG( + listOf(ActivityType.SET_OUTDATED_FLAG), + matcher = + DefaultMatcher( + entityClass = Translation::class, + revisionTypes = listOf(RevisionType.MOD), + modificationProps = listOf(Translation::outdated), + ), + ), + + ADD_TRANSLATION_COMMENT( + listOf(ActivityType.TRANSLATION_COMMENT_ADD), + matcher = + DefaultMatcher( + entityClass = TranslationComment::class, + revisionTypes = listOf(RevisionType.ADD), + ).or( + DefaultMatcher( + entityClass = Translation::class, + revisionTypes = listOf(RevisionType.ADD), + ), + ), + ), + + DELETE_TRANSLATION_COMMENT( + listOf(ActivityType.TRANSLATION_COMMENT_DELETE), + matcher = + DefaultMatcher( + entityClass = TranslationComment::class, + revisionTypes = listOf(RevisionType.DEL), + ), + ), + + EDIT_TRANSLATION_COMMENT( + listOf(ActivityType.TRANSLATION_COMMENT_EDIT), + matcher = + DefaultMatcher( + entityClass = TranslationComment::class, + revisionTypes = listOf(RevisionType.MOD), + modificationProps = listOf(TranslationComment::text), + ), + ), + + SET_TRANSLATION_COMMENT_STATE( + listOf(ActivityType.TRANSLATION_COMMENT_SET_STATE), + matcher = + DefaultMatcher( + entityClass = TranslationComment::class, + revisionTypes = listOf(RevisionType.MOD), + modificationProps = listOf(TranslationComment::state), + ), + ), + + DELETE_SCREENSHOT( + listOf(ActivityType.SCREENSHOT_DELETE, ActivityType.COMPLEX_EDIT), + matcher = + DefaultMatcher( + entityClass = Screenshot::class, + revisionTypes = listOf(RevisionType.DEL), + ).or( + DefaultMatcher( + entityClass = KeyScreenshotReference::class, + revisionTypes = listOf(RevisionType.DEL), + ), + ), + ), + + ADD_SCREENSHOT( + listOf(ActivityType.SCREENSHOT_ADD, ActivityType.COMPLEX_EDIT), + matcher = + DefaultMatcher( + entityClass = Screenshot::class, + revisionTypes = listOf(RevisionType.ADD), + ).or( + DefaultMatcher( + entityClass = KeyScreenshotReference::class, + revisionTypes = listOf(RevisionType.ADD), + ), + ), + ), + + EDIT_KEY_TAGS( + listOf( + ActivityType.KEY_TAGS_EDIT, + ActivityType.COMPLEX_EDIT, + ActivityType.BATCH_TAG_KEYS, + ActivityType.BATCH_UNTAG_KEYS, + ), + matcher = + DefaultMatcher( + entityClass = KeyMeta::class, + revisionTypes = listOf(RevisionType.MOD, RevisionType.ADD), + modificationProps = listOf(KeyMeta::tags), + ).or( + DefaultMatcher( + entityClass = Tag::class, + revisionTypes = listOf(RevisionType.ADD, RevisionType.DEL), + ), + ), + ), + + IMPORT( + listOf(ActivityType.IMPORT), + ), + + CREATE_LANGUAGE( + listOf(ActivityType.CREATE_LANGUAGE), + matcher = + DefaultMatcher( + entityClass = Language::class, + revisionTypes = listOf(RevisionType.ADD), + ), + ), + + EDIT_LANGUAGE( + listOf(ActivityType.EDIT_LANGUAGE), + matcher = + DefaultMatcher( + entityClass = Language::class, + revisionTypes = listOf(RevisionType.MOD), + modificationProps = listOf(Language::name, Language::tag, Language::originalName, Language::flagEmoji), + ), + ), + + DELETE_LANGUAGE( + listOf(ActivityType.DELETE_LANGUAGE), + matcher = + DefaultMatcher( + entityClass = Language::class, + revisionTypes = listOf(RevisionType.DEL), + ), + ), + + CREATE_PROJECT( + listOf(ActivityType.CREATE_PROJECT), + matcher = + DefaultMatcher( + entityClass = Project::class, + revisionTypes = listOf(RevisionType.ADD), + ), + modelProviderFactoryClass = CreateProjectGroupModelProvider::class, + ), + + EDIT_PROJECT( + listOf(ActivityType.EDIT_PROJECT), + matcher = + DefaultMatcher( + entityClass = Project::class, + revisionTypes = listOf(RevisionType.MOD), + modificationProps = + listOf( + Project::name, + Project::description, + Project::baseLanguage, + Project::defaultNamespace, + Project::avatarHash, + ), + ), + ), + + NAMESPACE_EDIT( + listOf(ActivityType.NAMESPACE_EDIT, ActivityType.BATCH_SET_KEYS_NAMESPACE), + matcher = + DefaultMatcher( + entityClass = Namespace::class, + revisionTypes = listOf(RevisionType.MOD), + modificationProps = listOf(Namespace::name), + ), + ), + + BATCH_PRE_TRANSLATE_BY_TM( + listOf(ActivityType.BATCH_PRE_TRANSLATE_BY_TM), + matcher = + DefaultMatcher( + entityClass = Translation::class, + revisionTypes = listOf(RevisionType.MOD, RevisionType.ADD), + modificationProps = listOf(Translation::state, Translation::text, Translation::outdated, Translation::auto), + ), + ), + + BATCH_MACHINE_TRANSLATE( + listOf(ActivityType.BATCH_MACHINE_TRANSLATE), + matcher = + DefaultMatcher( + entityClass = Translation::class, + revisionTypes = listOf(RevisionType.MOD, RevisionType.ADD), + modificationProps = listOf(Translation::state, Translation::text, Translation::outdated, Translation::auto), + ), + ), + + AUTO_TRANSLATE( + listOf(ActivityType.AUTO_TRANSLATE), + matcher = + DefaultMatcher( + entityClass = Translation::class, + revisionTypes = listOf(RevisionType.MOD, RevisionType.ADD), + modificationProps = listOf(Translation::state, Translation::text, Translation::outdated, Translation::auto), + ), + ), + + BATCH_CLEAR_TRANSLATIONS( + listOf(ActivityType.BATCH_CLEAR_TRANSLATIONS), + matcher = + DefaultMatcher( + entityClass = Translation::class, + revisionTypes = listOf(RevisionType.MOD), + modificationProps = listOf(Translation::state, Translation::text, Translation::outdated, Translation::auto), + allowedValues = + mapOf( + Translation::text to null, + Translation::state to null, + Translation::outdated to false, + Translation::auto to false, + ), + ), + ), + + BATCH_COPY_TRANSLATIONS( + listOf(ActivityType.BATCH_COPY_TRANSLATIONS), + matcher = + DefaultMatcher( + entityClass = Translation::class, + revisionTypes = listOf(RevisionType.MOD, RevisionType.ADD), + modificationProps = listOf(Translation::state, Translation::text, Translation::outdated, Translation::auto), + ), + ), + + BATCH_SET_TRANSLATION_STATE( + listOf(ActivityType.BATCH_SET_TRANSLATION_STATE), + matcher = + DefaultMatcher( + entityClass = Translation::class, + revisionTypes = listOf(RevisionType.MOD), + modificationProps = listOf(Translation::state, Translation::outdated, Translation::auto), + ), + ), + + CONTENT_DELIVERY_CONFIG_CREATE( + listOf(ActivityType.CONTENT_DELIVERY_CONFIG_CREATE), + matcher = + DefaultMatcher( + entityClass = ContentDeliveryConfig::class, + revisionTypes = listOf(RevisionType.ADD), + ), + ), + + CONTENT_DELIVERY_CONFIG_UPDATE( + listOf(ActivityType.CONTENT_DELIVERY_CONFIG_UPDATE), + matcher = + DefaultMatcher( + entityClass = ContentDeliveryConfig::class, + revisionTypes = listOf(RevisionType.MOD), + ), + ), + + CONTENT_DELIVERY_CONFIG_DELETE( + listOf(ActivityType.CONTENT_DELIVERY_CONFIG_UPDATE), + matcher = + DefaultMatcher( + entityClass = ContentDeliveryConfig::class, + revisionTypes = listOf(RevisionType.DEL), + ), + ), + + CONTENT_STORAGE_CREATE( + listOf(ActivityType.CONTENT_STORAGE_CREATE), + matcher = + DefaultMatcher( + entityClass = ContentStorage::class, + revisionTypes = listOf(RevisionType.ADD), + ), + ), + + CONTENT_STORAGE_UPDATE( + listOf(ActivityType.CONTENT_STORAGE_UPDATE), + matcher = + DefaultMatcher( + entityClass = ContentStorage::class, + revisionTypes = listOf(RevisionType.MOD), + ), + ), + + CONTENT_STORAGE_DELETE( + listOf(ActivityType.CONTENT_STORAGE_DELETE), + matcher = + DefaultMatcher( + entityClass = ContentStorage::class, + revisionTypes = listOf(RevisionType.DEL), + ), + ), + + WEBHOOK_CONFIG_CREATE( + listOf(ActivityType.WEBHOOK_CONFIG_CREATE), + matcher = + DefaultMatcher( + entityClass = WebhookConfig::class, + revisionTypes = listOf(RevisionType.ADD), + ), + ), + + WEBHOOK_CONFIG_UPDATE( + listOf(ActivityType.WEBHOOK_CONFIG_CREATE), + matcher = + DefaultMatcher( + entityClass = WebhookConfig::class, + revisionTypes = listOf(RevisionType.MOD), + ), + ), + + WEBHOOK_CONFIG_DELETE( + listOf(ActivityType.WEBHOOK_CONFIG_CREATE), + matcher = + DefaultMatcher( + entityClass = WebhookConfig::class, + revisionTypes = listOf(RevisionType.DEL), + ), + ), + + ; + + fun getProvidingModelTypes(): Pair?, KClass<*>?>? { + val arguments = + modelProviderFactoryClass + ?.supertypes + ?.firstOrNull() + ?.arguments ?: return null + + val groupType = + arguments.firstOrNull() + ?.type + ?.classifier as? KClass<*> + + val itemType = + arguments[1] + .type + ?.classifier as? KClass<*> + + return groupType to itemType + } + + companion object { + fun getOrderedTypes(): List { + val withType = ActivityGroupType.entries.filter { it.orderAfter != null } + val referenced = withType.mapNotNull { it.orderAfter } + val all = (withType + referenced).toSet() + return all.sortedWith { a, b -> + when { + a.orderAfter == b -> 1 + b.orderAfter == a -> -1 + else -> 0 + } + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGrouper.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGrouper.kt new file mode 100644 index 0000000000..552a72f1a6 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGrouper.kt @@ -0,0 +1,91 @@ +package io.tolgee.activity.groups + +import io.tolgee.activity.ModifiedEntitiesType +import io.tolgee.activity.groups.matchers.modifiedEntity.StoringContext +import io.tolgee.model.activity.ActivityModifiedEntity +import io.tolgee.model.activity.ActivityRevision +import jakarta.persistence.EntityManager +import org.springframework.context.ApplicationContext + +class ActivityGrouper( + private val activityRevision: ActivityRevision, + private val modifiedEntities: ModifiedEntitiesType, + private val applicationContext: ApplicationContext, +) { + fun addToGroup() { + val groupTypes = findGroupTypes() + groupTypes.forEach { (type, matchingStrings) -> + getActivityGroupIds(type, matchingStrings).forEach { group -> + addToGroup(group.value) + } + } + } + + private fun addToGroup(groupId: Long) { + entityManager.createNativeQuery( + """ + insert into activity_revision_activity_groups (activity_revisions_id, activity_groups_id) + values (:activityRevisionId, :activityGroupId) + """, + ) + .setParameter("activityRevisionId", activityRevision.id) + .setParameter("activityGroupId", groupId) + .executeUpdate() + } + + private fun getActivityGroupIds( + type: ActivityGroupType, + matchingStrings: Set, + ): Map { + return activityGroupService.getOrCreateCurrentActivityGroupDto( + type, + matchingStrings, + activityRevision.projectId, + activityRevision.authorId, + ).mapValues { it.value.id } + } + + private fun findGroupTypes(): Map> { + return ActivityGroupType.entries.mapNotNull { activityGroupType -> + val matchingEntities = activityGroupType.matchingEntities + if (matchingEntities.isEmpty()) { + return@mapNotNull null + } + activityGroupType to + activityGroupType.matchingEntities.map { entity -> + activityGroupType.matchingStringProvider?.provide( + entity.getStoringContext(), + ) + }.toSet() + }.toMap() + } + + private val ActivityGroupType.matchingEntities: List + get() { + if (!this.sourceActivityTypes.contains(type)) { + return emptyList() + } + + return modifiedEntities.values.flatMap { + it.values.filter { entity -> entityMatches(entity) } + } + } + + private fun ActivityGroupType.entityMatches(entity: ActivityModifiedEntity): Boolean { + return this.matcher?.match(entity.getStoringContext()) ?: true + } + + private fun ActivityModifiedEntity.getStoringContext(): StoringContext { + return StoringContext(this, activityRevision) + } + + private val type = activityRevision.type + + private val activityGroupService by lazy { + applicationContext.getBean(ActivityGroupService::class.java) + } + + private val entityManager: EntityManager by lazy { + applicationContext.getBean(EntityManager::class.java) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGroupsProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGroupsProvider.kt new file mode 100644 index 0000000000..fec3490312 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/ActivityGroupsProvider.kt @@ -0,0 +1,148 @@ +package io.tolgee.activity.groups + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.tolgee.api.SimpleUserAccount +import io.tolgee.dtos.queryResults.ActivityGroupView +import io.tolgee.dtos.request.ActivityGroupFilters +import org.jooq.CaseWhenStep +import org.jooq.DSLContext +import org.jooq.JSON +import org.jooq.impl.DSL +import org.jooq.impl.DSL.field +import org.jooq.impl.DSL.jsonArrayAgg +import org.jooq.impl.DSL.max +import org.jooq.impl.DSL.table +import org.springframework.context.ApplicationContext +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import java.util.* + +class ActivityGroupsProvider( + val projectId: Long, + val pageable: Pageable, + val filters: ActivityGroupFilters, + applicationContext: ApplicationContext, +) { + fun get(): PageImpl { + page.forEach { + it.data = dataViews[it.id] + } + + return page + } + + private val page by lazy { + val from = table("activity_group").`as`("ag") + var where = field("ag.project_id").eq(projectId) + + var having = DSL.noCondition() + + val lmeJsonArrayAggField = jsonArrayAgg(field("lme.entity_id")).`as`("lme_entity_ids") + val ldeJsonArrayAggField = jsonArrayAgg(field("lde.entity_id")).`as`("lde_entity_ids") + + if (filters.filterType != null) { + where = where.and(field("ag.type").eq(filters.filterType!!.name)) + } + + if (filters.filterLanguageIdIn != null) { + val languageIdsJson = JSON.json(objectMapper.writeValueAsString(filters.filterLanguageIdIn)) + having = lmeJsonArrayAggField.contains(languageIdsJson).and(ldeJsonArrayAggField.contains(languageIdsJson)) + } + + if (filters.filterAuthorUserIdIn != null) { + where = where.and(field("ag.author_id").`in`(filters.filterAuthorUserIdIn)) + } + + val count = jooqContext.selectCount().from(from).where(where).fetchOne(0, Long::class.java)!! + + val result = + jooqContext.select( + field("ag.id"), + field("ag.type"), + field("ag.author_id"), + max(field("ar.timestamp")), + field("ua.id"), + field("ua.username"), + field("ua.name"), + field("ua.avatar_hash"), + field("ua.deleted_at").isNotNull, + lmeJsonArrayAggField, + ldeJsonArrayAggField, + ) + .from(from) + .leftJoin(table("activity_revision_activity_groups").`as`("arag")) + .on(field("ag.id").eq(field("arag.activity_groups_id"))) + .leftJoin(table("activity_revision").`as`("ar")) + .on(field("ar.id").eq(field("arag.activity_revisions_id"))) + .leftJoin(table("user_account").`as`("ua")).on(field("ag.author_id").eq(field("ua.id"))) + .leftJoin(table("activity_modified_entity").`as`("lme")).on( + field("ar.id").eq(field("lme.activity_revision_id")) + .and(field("lme.entity_class").eq("Language")), + ).leftJoin(table("activity_describing_entity").`as`("lde")).on( + field("ar.id").eq(field("lde.activity_revision_id")) + .and(field("lde.entity_class").eq("Language")), + ) + .where(where) + .groupBy(field("ag.id"), field("ua.id")) + .having(having) + .orderBy(max(field("ar.timestamp")).desc(), orderedTypesField?.desc()) + .limit(pageable.pageSize) + .offset(pageable.offset).fetch().map { + ActivityGroupView( + it[0] as Long, + ActivityGroupType.valueOf(it[1] as String), + it[3] as Date, + author = + object : SimpleUserAccount { + override val id: Long = it[4] as Long + override val username: String = it[5] as String + override val name: String = it[6] as String + override val avatarHash: String? = it[7] as String? + override val deleted: Boolean = it[8] as Boolean + }, + mentionedLanguageIds = parseMentionedLanguageIds(it), + ) + } + + PageImpl(result, pageable, count) + } + + private fun parseMentionedLanguageIds(it: org.jooq.Record): List { + val lmeIds = it.getJsonValue>("lme_entity_ids") ?: emptyList() + val ldeIds = it.getJsonValue>("lde_entity_ids") ?: emptyList() + return (lmeIds + ldeIds).filterNotNull().toSet().toList() + } + + private inline fun org.jooq.Record.getJsonValue(fieldName: String): T? { + val string = this.getValue(fieldName, String::class.java) ?: return null + return objectMapper.readValue(string) + } + + private val dataViews by lazy { + byType.flatMap { (type, items) -> + val provider = + type.modelProviderFactoryClass?.let { applicationContext.getBean(it.java) } + provider?.provideGroup(items.map { it.id })?.map { it.key to it.value } ?: emptyList() + }.toMap() + } + + private val byType by lazy { page.groupBy { it.type } } + + private val jooqContext = applicationContext.getBean(DSLContext::class.java) + + private val objectMapper = applicationContext.getBean(ObjectMapper::class.java) + + private val orderedTypesField by lazy { + val choose = DSL.choose(field("ag.type")) + var whenStep: CaseWhenStep? = null + ActivityGroupType.getOrderedTypes().mapIndexed { index, type -> + whenStep?.let { + whenStep = it.`when`(type.name, index) + } ?: let { + whenStep = choose.`when`(type.name, index) + } + } + whenStep?.otherwise(0) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/GroupModelProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/GroupModelProvider.kt new file mode 100644 index 0000000000..973495964a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/GroupModelProvider.kt @@ -0,0 +1,13 @@ +package io.tolgee.activity.groups + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface GroupModelProvider { + fun provideGroup(groupIds: List): Map + + fun provideItems( + groupId: Long, + pageable: Pageable, + ): Page +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/baseModelAssemblers/ActivityKeyGroupModelAssembler.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/baseModelAssemblers/ActivityKeyGroupModelAssembler.kt new file mode 100644 index 0000000000..0bcb680ca9 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/baseModelAssemblers/ActivityKeyGroupModelAssembler.kt @@ -0,0 +1,18 @@ +package io.tolgee.activity.groups.baseModelAssemblers + +import io.tolgee.activity.groups.baseModels.ActivityGroupKeyModel +import io.tolgee.activity.groups.data.ActivityEntityView +import io.tolgee.activity.groups.data.getAdditionalDescriptionFieldNullable +import io.tolgee.activity.groups.data.getFieldFromView +import io.tolgee.model.key.Key + +class ActivityKeyGroupModelAssembler : GroupModelAssembler { + override fun toModel(entity: ActivityEntityView): ActivityGroupKeyModel { + return ActivityGroupKeyModel( + id = entity.entityId, + name = entity.getFieldFromView(Key::name.name), + namespace = null, + baseTranslationText = entity.getAdditionalDescriptionFieldNullable("baseTranslation", "text"), + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/baseModelAssemblers/GroupModelAssembler.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/baseModelAssemblers/GroupModelAssembler.kt new file mode 100644 index 0000000000..686d19fbab --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/baseModelAssemblers/GroupModelAssembler.kt @@ -0,0 +1,7 @@ +package io.tolgee.activity.groups.baseModelAssemblers + +import io.tolgee.activity.groups.data.ActivityEntityView + +interface GroupModelAssembler { + fun toModel(entity: ActivityEntityView): T +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/baseModels/ActivityGroupKeyModel.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/baseModels/ActivityGroupKeyModel.kt new file mode 100644 index 0000000000..ebcb0af830 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/baseModels/ActivityGroupKeyModel.kt @@ -0,0 +1,8 @@ +package io.tolgee.activity.groups.baseModels + +class ActivityGroupKeyModel( + val id: Long, + val name: String, + val namespace: String?, + val baseTranslationText: String?, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/baseModels/ActivityGroupLanguageModel.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/baseModels/ActivityGroupLanguageModel.kt new file mode 100644 index 0000000000..a40c075219 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/baseModels/ActivityGroupLanguageModel.kt @@ -0,0 +1,11 @@ +package io.tolgee.activity.groups.baseModels + +import io.tolgee.api.ILanguageModel + +class ActivityGroupLanguageModel( + override val id: Long, + override val name: String, + override val originalName: String, + override val tag: String, + override val flagEmoji: String, +) : ILanguageModel diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/ActivityEntityView.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/ActivityEntityView.kt new file mode 100644 index 0000000000..41459974fe --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/ActivityEntityView.kt @@ -0,0 +1,33 @@ +package io.tolgee.activity.groups.data + +interface ActivityEntityView { + val activityRevisionId: Long + val entityId: Long + val entityClass: String + val additionalDescription: Map? +} + +inline fun ActivityEntityView.getFieldFromViewNullable(name: String): T? { + return when (this) { + is DescribingEntityView -> + this.data[name] as? T + + is ModifiedEntityView -> + this.describingData[name] as? T ?: this.modifications[name]?.let { it.new as? T } + + else -> throw IllegalArgumentException("Unknown entity view type") + } +} + +inline fun ActivityEntityView.getFieldFromView(name: String): T { + return this.getFieldFromViewNullable(name) + ?: throw IllegalArgumentException("Field $name not found in describing data") +} + +inline fun ActivityEntityView.getAdditionalDescriptionFieldNullable( + fieldName: String, + propertyName: String, +): T? { + val field = this.additionalDescription?.get(fieldName) as? Map + return field?.get(propertyName) as? T +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/DescribingEntityView.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/DescribingEntityView.kt new file mode 100644 index 0000000000..283828139c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/DescribingEntityView.kt @@ -0,0 +1,20 @@ +package io.tolgee.activity.groups.data + +import io.tolgee.activity.data.EntityDescriptionRef + +class DescribingEntityView( + override val entityId: Long, + override val entityClass: String, + val data: Map, + val describingRelations: Map, + override val activityRevisionId: Long, +) : ActivityEntityView { + inline fun getFieldFromViewNullable(name: String): T? { + return this.data[name] as? T + } + + inline fun getFieldFromView(name: String): T { + return this.data[name] as? T + ?: throw IllegalArgumentException("Field $name not found in describing data") + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/DescribingMapping.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/DescribingMapping.kt new file mode 100644 index 0000000000..0b69c1fe81 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/DescribingMapping.kt @@ -0,0 +1,9 @@ +package io.tolgee.activity.groups.data + +import io.tolgee.model.StandardAuditModel +import kotlin.reflect.KClass + +data class DescribingMapping( + val entityClass: KClass, + val field: String, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/ModifiedEntityView.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/ModifiedEntityView.kt new file mode 100644 index 0000000000..6c0f7dd7cc --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/ModifiedEntityView.kt @@ -0,0 +1,13 @@ +package io.tolgee.activity.groups.data + +import io.tolgee.activity.data.EntityDescriptionRef +import io.tolgee.activity.data.PropertyModification + +class ModifiedEntityView( + val entityId: Long, + val entityClass: String, + val describingData: Map, + val describingRelations: Map, + val modifications: Map>, + val activityRevisionId: Long, +) : ActivityEntityView diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/RelatedMapping.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/RelatedMapping.kt new file mode 100644 index 0000000000..985e4faf7a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/data/RelatedMapping.kt @@ -0,0 +1,10 @@ +package io.tolgee.activity.groups.data + +import io.tolgee.model.StandardAuditModel +import kotlin.reflect.KClass + +data class RelatedMapping( + val entityClass: KClass, + val field: String, + val entities: Iterable, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/CountsProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/CountsProvider.kt new file mode 100644 index 0000000000..093ba08583 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/CountsProvider.kt @@ -0,0 +1,75 @@ +package io.tolgee.activity.groups.dataProviders + +import io.tolgee.activity.groups.ActivityGroupType +import io.tolgee.activity.groups.matchers.modifiedEntity.SqlContext +import org.jooq.Condition +import org.jooq.DSLContext +import org.jooq.JSON +import org.jooq.impl.DSL + +class CountsProvider( + private val jooqContext: DSLContext, + private val groupType: ActivityGroupType, + private val entityClasses: List, + private val groupIds: List, +) { + private val activityModifiedEntityTable = DSL.table("activity_modified_entity").`as`("ame") + private val entityClassField = DSL.field("ame.entity_class", String::class.java) + private val activityRevisionTable = DSL.table("activity_revision").`as`("ar") + private val activityRevisionActivityGroupsTable = DSL.table("activity_revision_activity_groups").`as`("arag") + private val activityGroupTable = DSL.table("activity_group").`as`("ag") + private val groupIdField = DSL.field("arag.activity_groups_id", Long::class.java) + private val countField = DSL.count() + + fun provide(): Map> { + val queryResult = + jooqContext + .select( + groupIdField, + entityClassField, + countField, + ) + .from(activityModifiedEntityTable) + .join(activityRevisionTable) + .on( + DSL.field("ame.activity_revision_id", Long::class.java) + .eq(DSL.field("ar.id", Long::class.java)), + ) + .join(activityRevisionActivityGroupsTable) + .on( + DSL.field("ar.id", Long::class.java) + .eq(DSL.field("arag.activity_revisions_id", Long::class.java)), + ) + .join(activityGroupTable).on( + groupIdField.eq(DSL.field("ag.id", Long::class.java)), + ) + .where( + groupIdField.`in`(groupIds) + .and(entityClassField.`in`(entityClasses)) + .and(groupType.matcher?.match(sqlContext)) + .and(getStringMatcherCondition()), + ) + .groupBy(entityClassField, groupIdField) + .fetch() + + return queryResult.groupBy { groupIdField.get(it)!! }.mapValues { rows -> + rows.value.associate { entityClassField.getValue(it)!! to countField.getValue(it)!! } + } + } + + private val sqlContext by lazy { + SqlContext( + modificationsField = DSL.field("ame.modifications", JSON::class.java), + entityClassField = entityClassField, + revisionTypeField = DSL.field("ame.revision_type", Int::class.java), + groupIdField = groupIdField, + baseLanguageField = DSL.field("ar.base_language_id", Long::class.java), + describingRelationsField = DSL.field("ame.describing_relations", JSON::class.java), + ) + } + + private fun getStringMatcherCondition(): Condition { + return groupType.matchingStringProvider?.provide(sqlContext) + ?.eq(DSL.field("ag.matching_string", String::class.java)) ?: DSL.noCondition() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/DescribingEntitiesProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/DescribingEntitiesProvider.kt new file mode 100644 index 0000000000..101bf87d31 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/DescribingEntitiesProvider.kt @@ -0,0 +1,79 @@ +package io.tolgee.activity.groups.dataProviders + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.tolgee.activity.groups.data.DescribingEntityView +import io.tolgee.activity.groups.data.DescribingMapping +import io.tolgee.activity.groups.data.ModifiedEntityView +import org.jooq.DSLContext +import org.jooq.JSON +import org.jooq.impl.DSL + +class DescribingEntitiesProvider( + private val jooqContext: DSLContext, + private val objectMapper: ObjectMapper, + private val describingMapping: List, + private val entities: Iterable, +) { + private val describingEntityTable = DSL.table("activity_describing_entity").`as`("ade") + private val entityIdField = DSL.field("ade.entity_id", Long::class.java) + private val entityClassField = DSL.field("ade.entity_class", String::class.java) + private val dataField = DSL.field("ade.data", JSON::class.java) + private val describingRelationsField = DSL.field("ade.describing_relations", JSON::class.java) + private val activityRevisionIdField = DSL.field("ade.activity_revision_id", Long::class.java) + + fun provide(): Map> { + val views = + query.fetch() + .map { + DescribingEntityView( + entityId = it.get(entityIdField), + entityClass = it.get(entityClassField), + data = objectMapper.readValue(it.get(dataField).data()), + describingRelations = objectMapper.readValue(it.get(describingRelationsField).data()), + activityRevisionId = it.get(activityRevisionIdField), + ) + } + + val grouped = refs.groupBy { it.first } + + return grouped.mapValues { (_, refs) -> + refs.mapNotNull { (entity, ref) -> + views.find { + it.entityId == ref.entityId && + it.entityClass == ref.entityClass && + it.activityRevisionId == entity.activityRevisionId + } + } + } + } + + val query by lazy { + jooqContext + .select(entityIdField, entityClassField, dataField, describingRelationsField, activityRevisionIdField) + .from(describingEntityTable) + .where(condition) + } + + val condition by lazy { + DSL.or( + refs.map { (entity, ref) -> + DSL.condition(entityClassField.eq(ref.entityClass)) + .and(entityIdField.eq(ref.entityId)) + .and(activityRevisionIdField.eq(entity.activityRevisionId)) + }, + ) + } + + private val refs by lazy { + describingMapping.flatMap { mapping -> + entities.mapNotNull { entity -> + if (entity.entityClass != mapping.entityClass.simpleName) { + return@mapNotNull null + } + val ref = entity.describingRelations[mapping.field] ?: return@mapNotNull null + return@mapNotNull entity to ref + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/GroupDataProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/GroupDataProvider.kt new file mode 100644 index 0000000000..1e10b743e4 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/GroupDataProvider.kt @@ -0,0 +1,118 @@ +package io.tolgee.activity.groups.dataProviders + +import com.fasterxml.jackson.databind.ObjectMapper +import io.tolgee.activity.groups.ActivityGroupType +import io.tolgee.activity.groups.data.DescribingEntityView +import io.tolgee.activity.groups.data.DescribingMapping +import io.tolgee.activity.groups.data.ModifiedEntityView +import io.tolgee.activity.groups.data.RelatedMapping +import io.tolgee.model.EntityWithId +import org.jooq.DSLContext +import org.jooq.impl.DSL +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Component +import kotlin.reflect.KClass + +@Component +class GroupDataProvider( + private val jooqContext: DSLContext, + private val objectMapper: ObjectMapper, +) { + fun provideCounts( + groupType: ActivityGroupType, + entityClass: KClass, + groupIds: List, + ): Map { + val simpleNameString = entityClass.java.simpleName + + val result = + CountsProvider( + groupType = groupType, + entityClasses = listOf(simpleNameString), + groupIds = groupIds, + jooqContext = jooqContext, + ).provide() + + return result.map { (groupId, counts) -> + groupId to (counts[simpleNameString] ?: 0) + }.toMap() + } + + fun provideRelevantModifiedEntities( + groupType: ActivityGroupType, + entityClass: KClass, + groupId: Long, + pageable: Pageable, + ): Page { + val simpleNameString = entityClass.java.simpleName + + return PagedRelevantModifiedEntitiesProvider( + jooqContext = jooqContext, + objectMapper = objectMapper, + groupType = groupType, + entityClasses = listOf(simpleNameString), + groupId = groupId, + pageable = pageable, + ).provide() + } + + fun getRelatedEntities( + groupType: ActivityGroupType, + relatedMappings: List, + groupId: Long, + ): Map>> { + val entityClasses = relatedMappings.map { it.entityClass }.toSet().map { it.java.simpleName } + + val entities = + RelevantModifiedEntitiesProvider( + jooqContext = jooqContext, + objectMapper = objectMapper, + groupType = groupType, + entityClasses = entityClasses, + additionalFilter = { context -> + DSL + .and( + context.groupIdField.eq(groupId), + ) + .and( + DSL.or( + relatedMappings.flatMap { mapping -> + mapping.entities.map { + DSL.condition( + "(${context.describingRelationsField.name} -> ? -> 'entityId')::bigint = ?", + mapping.field, + it.entityId, + ) + .and(context.entityClassField.eq(mapping.entityClass.simpleName)) + } + }, + ), + ) + }, + ).provide() + + val allParents = relatedMappings.flatMap { it.entities } + + return allParents.associateWith { parent -> + relatedMappings.associateWith { mapping -> + entities.filter { entity -> + entity.entityClass == mapping.entityClass.simpleName && + entity.describingRelations.any { mapping.field == it.key && it.value?.entityId == parent.entityId } + } + } + } + } + + fun getDescribingEntities( + entities: Iterable, + describingMapping: List, + ): Map> { + return DescribingEntitiesProvider( + entities = entities, + jooqContext = jooqContext, + objectMapper = objectMapper, + describingMapping = describingMapping, + ).provide() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/PagedRelevantModifiedEntitiesProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/PagedRelevantModifiedEntitiesProvider.kt new file mode 100644 index 0000000000..404f1ffaae --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/PagedRelevantModifiedEntitiesProvider.kt @@ -0,0 +1,36 @@ +package io.tolgee.activity.groups.dataProviders + +import com.fasterxml.jackson.databind.ObjectMapper +import io.tolgee.activity.groups.ActivityGroupType +import io.tolgee.activity.groups.data.ModifiedEntityView +import org.jooq.DSLContext +import org.jooq.impl.DSL +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable + +class PagedRelevantModifiedEntitiesProvider( + jooqContext: DSLContext, + objectMapper: ObjectMapper, + groupType: ActivityGroupType, + entityClasses: List, + groupId: Long, + private val pageable: Pageable, +) { + private val baseProvider = + RelevantModifiedEntitiesProvider( + jooqContext = jooqContext, + objectMapper = objectMapper, + groupType = groupType, + entityClasses = entityClasses, + additionalFilter = { it.groupIdField.eq(groupId) }, + ) + + fun provide(): Page { + val count = getCountQuery().fetchOne(0, Int::class.java) ?: 0 + val content = baseProvider.provide(pageable) + return PageImpl(content, pageable, count.toLong()) + } + + private fun getCountQuery() = baseProvider.getQueryBase(DSL.count()) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/RelevantModifiedEntitiesProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/RelevantModifiedEntitiesProvider.kt new file mode 100644 index 0000000000..78a3def657 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/dataProviders/RelevantModifiedEntitiesProvider.kt @@ -0,0 +1,94 @@ +package io.tolgee.activity.groups.dataProviders + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.tolgee.activity.groups.ActivityGroupType +import io.tolgee.activity.groups.data.ModifiedEntityView +import io.tolgee.activity.groups.matchers.modifiedEntity.SqlContext +import org.jooq.Condition +import org.jooq.DSLContext +import org.jooq.JSON +import org.jooq.SelectField +import org.jooq.impl.DSL +import org.springframework.data.domain.Pageable + +class RelevantModifiedEntitiesProvider( + private val jooqContext: DSLContext, + private val objectMapper: ObjectMapper, + private val groupType: ActivityGroupType, + private val entityClasses: List, + private val additionalFilter: ((SqlContext) -> Condition)? = null, +) { + private val activityModifiedEntityTable = DSL.table("activity_modified_entity").`as`("ame") + private val activityRevisionTable = DSL.table("activity_revision").`as`("ar") + private val activityRevisionActivityGroupsTable = DSL.table("activity_revision_activity_groups").`as`("arag") + private val groupIdField = DSL.field("arag.activity_groups_id", Long::class.java) + private val entityClassField = DSL.field("ame.entity_class", String::class.java) + private val entityIdField = DSL.field("ame.entity_id", Long::class.java) + private val describingDataField = DSL.field("ame.describing_data", JSON::class.java) + private val describingRelationsField = DSL.field("ame.describing_relations", JSON::class.java) + private val modificationsField = DSL.field("ame.modifications", JSON::class.java) + private val activityRevisionId = DSL.field("ame.activity_revision_id", Long::class.java) + + private val sqlContext = + SqlContext( + modificationsField = DSL.field("ame.modifications", JSON::class.java), + entityClassField = entityClassField, + revisionTypeField = DSL.field("ame.revision_type", Int::class.java), + groupIdField = groupIdField, + baseLanguageField = DSL.field("ar.base_language_id", Long::class.java), + describingRelationsField = describingRelationsField, + ) + + fun getQueryBase(vararg fields: SelectField<*>) = + jooqContext + .select(*fields) + .from(activityModifiedEntityTable) + .join(activityRevisionTable) + .on( + DSL.field("ame.activity_revision_id", Long::class.java) + .eq(DSL.field("ar.id", Long::class.java)), + ) + .join(activityRevisionActivityGroupsTable) + .on( + DSL.field("ar.id", Long::class.java) + .eq(DSL.field("arag.activity_revisions_id", Long::class.java)), + ) + .where( + DSL.and(entityClassField.`in`(entityClasses)).and( + groupType.matcher?.match(sqlContext), + ).and(additionalFilter?.let { it(sqlContext) } ?: DSL.noCondition()), + ) + + private fun getDataQuery() = + getQueryBase( + entityIdField, + entityClassField, + describingDataField, + describingRelationsField, + modificationsField, + activityRevisionId, + ) + + fun provide(pageable: Pageable? = null): List { + val query = + getDataQuery().orderBy(activityRevisionId.desc()).also { query -> + pageable?.let { + query.limit(it.pageSize).offset(it.offset) + } + } + + val queryResult = query.fetch() + + return queryResult.map { + ModifiedEntityView( + entityId = it.get(entityIdField)!!, + entityClass = it.get(entityClassField)!!, + describingData = objectMapper.readValue(it.get(describingDataField).data()), + describingRelations = objectMapper.readValue(it.get(describingRelationsField).data()), + modifications = objectMapper.readValue(it.get(modificationsField).data()), + activityRevisionId = activityRevisionId.get(it)!!, + ) + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/ActivityGroupValueMatcher.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/ActivityGroupValueMatcher.kt new file mode 100644 index 0000000000..9fafd88c6c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/ActivityGroupValueMatcher.kt @@ -0,0 +1,22 @@ +package io.tolgee.activity.groups.matchers + +import org.jooq.Condition +import org.jooq.Field +import org.jooq.JSON + +interface ActivityGroupValueMatcher { + fun match(value: Any?): Boolean + + fun createChildSqlCondition(field: Field): Condition + + fun createRootSqlCondition(field: Field): Condition + + companion object { + fun notNull() = NotNullValueMatcher() + + fun modification(pair: Pair) = + ModificationValueMatcher(pair.first, pair.second) + + fun eq(value: Any?) = EqualsValueMatcher(value) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/EqualsValueMatcher.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/EqualsValueMatcher.kt new file mode 100644 index 0000000000..113cdc148c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/EqualsValueMatcher.kt @@ -0,0 +1,30 @@ +package io.tolgee.activity.groups.matchers + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.tolgee.activity.data.PropertyModification +import org.jooq.Condition +import org.jooq.Field +import org.jooq.JSON +import org.jooq.impl.DSL + +class EqualsValueMatcher(val value: Any?) : ActivityGroupValueMatcher { + override fun match(value: Any?): Boolean { + if (value is PropertyModification) { + return value.new == this.value + } + return this.value == value + } + + override fun createChildSqlCondition(field: Field): Condition { + return field.eq(jsonValue) + } + + override fun createRootSqlCondition(field: Field): Condition { + val new = DSL.jsonGetAttribute(field, "new") + return createChildSqlCondition(new) + } + + private val jsonValue by lazy { + JSON.json(jacksonObjectMapper().writeValueAsString(value)) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/ModificationValueMatcher.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/ModificationValueMatcher.kt new file mode 100644 index 0000000000..4f000d97a1 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/ModificationValueMatcher.kt @@ -0,0 +1,29 @@ +package io.tolgee.activity.groups.matchers + +import io.tolgee.activity.data.PropertyModification +import org.jooq.Condition +import org.jooq.Field +import org.jooq.JSON +import org.jooq.impl.DSL + +class ModificationValueMatcher( + val old: ActivityGroupValueMatcher, + val new: ActivityGroupValueMatcher, +) : ActivityGroupValueMatcher { + override fun match(value: Any?): Boolean { + if (value is PropertyModification) { + return old.match(value.old) && new.match(value.new) + } + throw IllegalArgumentException("Value is not PropertyModification") + } + + override fun createChildSqlCondition(field: Field): Condition { + throw UnsupportedOperationException("ModificationValueMatcher cannot be used as child condition") + } + + override fun createRootSqlCondition(field: Field): Condition { + val oldField = DSL.jsonGetAttribute(field, "old") + val newField = DSL.jsonGetAttribute(field, "new") + return old.createChildSqlCondition(oldField).and(new.createChildSqlCondition(newField)) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/NotNullValueMatcher.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/NotNullValueMatcher.kt new file mode 100644 index 0000000000..a49900fbf3 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/NotNullValueMatcher.kt @@ -0,0 +1,25 @@ +package io.tolgee.activity.groups.matchers + +import io.tolgee.activity.data.PropertyModification +import org.jooq.Condition +import org.jooq.Field +import org.jooq.JSON +import org.jooq.impl.DSL + +class NotNullValueMatcher : ActivityGroupValueMatcher { + override fun match(value: Any?): Boolean { + if (value is PropertyModification) { + return value.new != null + } + return value != null + } + + override fun createChildSqlCondition(field: Field): Condition { + return field.isNotNull + } + + override fun createRootSqlCondition(field: Field): Condition { + val new = DSL.jsonGetAttribute(field, "new") + return createChildSqlCondition(new) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/DefaultMatcher.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/DefaultMatcher.kt new file mode 100644 index 0000000000..c67027611c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/DefaultMatcher.kt @@ -0,0 +1,131 @@ +package io.tolgee.activity.groups.matchers.modifiedEntity + +import io.tolgee.activity.data.PropertyModification +import io.tolgee.activity.data.RevisionType +import io.tolgee.activity.groups.matchers.ActivityGroupValueMatcher +import io.tolgee.activity.groups.matchers.EqualsValueMatcher +import org.jooq.Condition +import org.jooq.impl.DSL +import kotlin.reflect.KClass +import kotlin.reflect.KProperty1 + +class DefaultMatcher( + val entityClass: KClass, + val revisionTypes: List, + val modificationProps: List>? = null, + val allowedValues: Map, Any?>? = null, + val deniedValues: Map, Any?>? = null, +) : ModifiedEntityMatcher { + override fun match(context: StoringContext): Boolean { + if (context.modifiedEntity.entityClass != entityClass.simpleName) { + return false + } + + if (context.modifiedEntity.revisionType !in revisionTypes) { + return false + } + + val hasModifiedColumn = + context.modifiedEntity.modifications.any { modification -> + modificationProps?.any { it.name == modification.key } ?: true + } + + if (!hasModifiedColumn) { + return false + } + + if (!isAllValuesAllowed(context)) { + return false + } + + return isNoValueDenied(context) + } + + private fun isAllValuesAllowed(context: StoringContext): Boolean { + return context.modifiedEntity.modifications.all { modification -> + val allowedValueDefinition = + allowedValues?.filterKeys { it.name == modification.key }?.values?.firstOrNull() ?: return@all true + compareValue(allowedValueDefinition, modification.value) + } + } + + private fun isNoValueDenied(context: StoringContext): Boolean { + val isAnyDenied = + context.modifiedEntity.modifications.any { modification -> + deniedValues?.any { it.key.name == modification.key && compareValue(it.value, modification.value) } ?: false + } + return !isAnyDenied + } + + private fun compareValue( + matcher: Any?, + modification: PropertyModification, + ): Boolean { + return when (matcher) { + is ActivityGroupValueMatcher -> matcher.match(modification) + else -> matcher == modification.new + } + } + + override fun match(context: SqlContext): Condition { + return DSL.and( + getEntityClassCondition(context), + getRevisionTypeCondition(context), + getModificationPropsCondition(context), + getAllowedValuesCondition(context), + getDeniedValuesCondition(context), + ) + } + + private fun getModificationPropsCondition(context: SqlContext): Condition { + if (modificationProps != null) { + val props = modificationProps.map { it.name }.toTypedArray() + return DSL.arrayOverlap( + DSL.field( + "array(select jsonb_object_keys(${context.modificationsField.name}))::varchar[]", + Array::class.java, + ), + props, + ) + } + return DSL.noCondition() + } + + private fun getRevisionTypeCondition(context: SqlContext): Condition { + return context.revisionTypeField.`in`(revisionTypes.map { it.ordinal }) + } + + private fun getEntityClassCondition(context: SqlContext): Condition { + return context.entityClassField.eq(entityClass.simpleName) + } + + private fun getAllowedModString(): String { + return "{${modificationProps?.joinToString(",") { "'${it.name}'" }}}" + } + + private fun getAllowedValuesCondition(context: SqlContext): Condition { + val allowedValues = allowedValues ?: return DSL.noCondition() + val conditions = getValueMatcherConditions(context, allowedValues) + return DSL.and(conditions) + } + + private fun getDeniedValuesCondition(context: SqlContext): Condition { + val deniedValues = deniedValues ?: return DSL.noCondition() + val conditions = getValueMatcherConditions(context, deniedValues) + return DSL.not(DSL.or(conditions)) + } + + private fun getValueMatcherConditions( + context: SqlContext, + values: Map, + ): List { + return values.map { + val matcher = + when (val requiredValue = it.value) { + is ActivityGroupValueMatcher -> requiredValue + else -> EqualsValueMatcher(requiredValue) + } + matcher.createRootSqlCondition(context.modificationsField) + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/MatchingStringProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/MatchingStringProvider.kt new file mode 100644 index 0000000000..8bb94bf8ec --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/MatchingStringProvider.kt @@ -0,0 +1,9 @@ +package io.tolgee.activity.groups.matchers.modifiedEntity + +import org.jooq.Field + +interface MatchingStringProvider { + fun provide(context: StoringContext): String? + + fun provide(context: SqlContext): Field? +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/ModifiedEntityMatcher.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/ModifiedEntityMatcher.kt new file mode 100644 index 0000000000..ea4bf7266a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/ModifiedEntityMatcher.kt @@ -0,0 +1,34 @@ +package io.tolgee.activity.groups.matchers.modifiedEntity + +import org.jooq.Condition +import org.jooq.impl.DSL + +interface ModifiedEntityMatcher { + fun match(context: StoringContext): Boolean + + fun match(context: SqlContext): Condition + + fun and(other: ModifiedEntityMatcher): ModifiedEntityMatcher { + return object : ModifiedEntityMatcher { + override fun match(context: StoringContext): Boolean { + return this@ModifiedEntityMatcher.match(context) && other.match(context) + } + + override fun match(context: SqlContext): Condition { + return DSL.and(this@ModifiedEntityMatcher.match(context), other.match(context)) + } + } + } + + fun or(other: ModifiedEntityMatcher): ModifiedEntityMatcher { + return object : ModifiedEntityMatcher { + override fun match(context: StoringContext): Boolean { + return this@ModifiedEntityMatcher.match(context) || other.match(context) + } + + override fun match(context: SqlContext): Condition { + return DSL.or(this@ModifiedEntityMatcher.match(context), other.match(context)) + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/SetTranslationMatchingStringProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/SetTranslationMatchingStringProvider.kt new file mode 100644 index 0000000000..b37979635d --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/SetTranslationMatchingStringProvider.kt @@ -0,0 +1,22 @@ +package io.tolgee.activity.groups.matchers.modifiedEntity + +import io.tolgee.model.translation.Translation +import org.jooq.Field +import org.jooq.impl.DSL + +class SetTranslationMatchingStringProvider : MatchingStringProvider { + override fun provide(context: StoringContext): String? { + if (context.modifiedEntity.entityClass != Translation::class.simpleName) { + return null + } + return context.modifiedEntity.describingRelations?.get("language")?.entityId?.toString() + ?: throw IllegalStateException("Language not found") + } + + override fun provide(context: SqlContext): Field { + return DSL.field( + "(${context.describingRelationsField.name} -> 'language' -> 'entityId')::varchar", + String::class.java, + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/SqlContext.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/SqlContext.kt new file mode 100644 index 0000000000..10ed5f84f7 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/SqlContext.kt @@ -0,0 +1,16 @@ +package io.tolgee.activity.groups.matchers.modifiedEntity + +import org.jooq.Field +import org.jooq.JSON + +/** + * This is all the context that can be used when data are queried from the database + */ +class SqlContext( + val modificationsField: Field, + var entityClassField: Field, + var revisionTypeField: Field, + val groupIdField: Field, + val describingRelationsField: Field, + val baseLanguageField: Field, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/StoringContext.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/StoringContext.kt new file mode 100644 index 0000000000..b4f22c751e --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/StoringContext.kt @@ -0,0 +1,12 @@ +package io.tolgee.activity.groups.matchers.modifiedEntity + +import io.tolgee.model.activity.ActivityModifiedEntity +import io.tolgee.model.activity.ActivityRevision + +/** + * This is all the context that is provided when storing the activity data + */ +class StoringContext( + val modifiedEntity: ActivityModifiedEntity, + val activityRevision: ActivityRevision, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/TranslationMatcher.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/TranslationMatcher.kt new file mode 100644 index 0000000000..086566710a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/matchers/modifiedEntity/TranslationMatcher.kt @@ -0,0 +1,70 @@ +package io.tolgee.activity.groups.matchers.modifiedEntity + +import io.tolgee.activity.data.RevisionType +import io.tolgee.model.translation.Translation +import org.jooq.Condition +import org.jooq.impl.DSL + +class TranslationMatcher( + private val type: Type = Type.BASE, +) : ModifiedEntityMatcher { + private val defaultMatcher by lazy { + DefaultMatcher( + entityClass = Translation::class, + revisionTypes = listOf(RevisionType.ADD, RevisionType.MOD), + modificationProps = listOf(Translation::text), + ) + } + + override fun match(context: StoringContext): Boolean { + if (!defaultMatcher.match(context)) { + return false + } + + if (type == Type.ANY) { + return true + } + + val entityId = + context.modifiedEntity.describingRelations?.get("language")?.entityId + // non-base is the default, so we return true if it is not base translation + ?: return type == Type.NON_BASE + + val isBase = context.activityRevision.baseLanguageId == entityId + + if (type == Type.BASE) { + return isBase + } + + return !isBase + } + + override fun match(context: SqlContext): Condition { + val baseCondition = defaultMatcher.match(context) + + if (type == Type.ANY) { + return baseCondition + } + + val baseLanguageCondition = context.getSqlCondition() + + if (type == Type.BASE) { + return baseCondition.and(baseLanguageCondition) + } + + return baseCondition.and(baseLanguageCondition.not()) + } + + private fun SqlContext.getSqlCondition(): Condition { + val sqlCondition = + "(${describingRelationsField.name} -> 'language' -> 'entityId')::bigint = ${baseLanguageField.name}" + + return DSL.condition(sqlCondition) + } + + enum class Type { + BASE, + NON_BASE, + ANY, + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createKey/CreateKeyGroupItemModel.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createKey/CreateKeyGroupItemModel.kt new file mode 100644 index 0000000000..39243287c7 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createKey/CreateKeyGroupItemModel.kt @@ -0,0 +1,23 @@ +package io.tolgee.activity.groups.viewProviders.createKey + +import io.swagger.v3.oas.annotations.media.Schema +import io.tolgee.api.IKeyModel +import io.tolgee.sharedDocs.Key +import org.springframework.hateoas.server.core.Relation + +@Relation(collectionRelation = "items", itemRelation = "item") +class CreateKeyGroupItemModel( + override val id: Long, + override val name: String, + override val namespace: String?, + @Schema(description = Key.IS_PLURAL_FIELD) + val isPlural: Boolean, + @Schema(description = Key.PLURAL_ARG_NAME_FIELD) + val pluralArgName: String?, + @Schema(description = "The base translation value entered when key was created") + val baseTranslationText: String?, + val tags: List, + override val description: String?, + override val custom: Map?, + val baseLanguageId: Long?, +) : IKeyModel diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createKey/CreateKeyGroupModel.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createKey/CreateKeyGroupModel.kt new file mode 100644 index 0000000000..04083fa011 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createKey/CreateKeyGroupModel.kt @@ -0,0 +1,5 @@ +package io.tolgee.activity.groups.viewProviders.createKey + +class CreateKeyGroupModel( + val keyCount: Int, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createKey/CreateKeyGroupModelProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createKey/CreateKeyGroupModelProvider.kt new file mode 100644 index 0000000000..0ffd861872 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createKey/CreateKeyGroupModelProvider.kt @@ -0,0 +1,113 @@ +package io.tolgee.activity.groups.viewProviders.createKey + +import io.tolgee.activity.groups.ActivityGroupType +import io.tolgee.activity.groups.GroupModelProvider +import io.tolgee.activity.groups.data.DescribingMapping +import io.tolgee.activity.groups.data.RelatedMapping +import io.tolgee.activity.groups.data.getFieldFromView +import io.tolgee.activity.groups.data.getFieldFromViewNullable +import io.tolgee.activity.groups.dataProviders.GroupDataProvider +import io.tolgee.model.key.Key +import io.tolgee.model.key.KeyMeta +import io.tolgee.model.key.Namespace +import io.tolgee.model.translation.Translation +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Component + +@Component +class CreateKeyGroupModelProvider( + private val groupDataProvider: GroupDataProvider, +) : + GroupModelProvider { + override fun provideGroup(groupIds: List): Map { + val keyCounts = + groupDataProvider.provideCounts(ActivityGroupType.CREATE_KEY, groupIds = groupIds, entityClass = Key::class) + + return groupIds.associateWith { + CreateKeyGroupModel( + keyCounts[it] ?: 0, + ) + } + } + + override fun provideItems( + groupId: Long, + pageable: Pageable, + ): Page { + val entities = + groupDataProvider.provideRelevantModifiedEntities( + ActivityGroupType.CREATE_KEY, + Key::class, + groupId, + pageable, + ) + + val translationMapping = + RelatedMapping( + entityClass = Translation::class, + field = "key", + entities, + ) + + val keyMetaMapping = + RelatedMapping( + entityClass = KeyMeta::class, + field = "key", + entities, + ) + + val relatedEntities = + groupDataProvider.getRelatedEntities( + groupType = ActivityGroupType.CREATE_KEY, + relatedMappings = + listOf( + keyMetaMapping, + translationMapping, + ), + groupId = groupId, + ) + + val descriptions = + groupDataProvider.getDescribingEntities( + entities, + listOf(DescribingMapping(Key::class, Key::namespace.name)), + ) + + return entities.map { entity -> + val baseTranslation = + relatedEntities[entity] + ?.get(translationMapping)?.singleOrNull() + + val baseTranslationText = + baseTranslation?.modifications + ?.get("text") + ?.new as? String + + val baseLanguageId = baseTranslation?.describingRelations?.get("language")?.entityId + + val keyMeta = + relatedEntities[entity] + ?.get(keyMetaMapping) + ?.singleOrNull() + + CreateKeyGroupItemModel( + entity.entityId, + name = entity.getFieldFromView(Key::name.name), + tags = (keyMeta?.getFieldFromViewNullable(KeyMeta::tags.name) as? List) ?: emptyList(), + description = keyMeta?.getFieldFromViewNullable(KeyMeta::description.name), + custom = keyMeta?.getFieldFromViewNullable(KeyMeta::custom.name), + isPlural = entity.getFieldFromView(Key::isPlural.name), + pluralArgName = entity.getFieldFromViewNullable(Key::pluralArgName.name), + namespace = + descriptions[entity] + ?.find { it.entityClass == Namespace::class.simpleName } + ?.data + ?.get(Namespace::name.name) + as? String, + baseTranslationText = baseTranslationText, + baseLanguageId = baseLanguageId, + ) + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createProject/CreateProjectGroupModel.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createProject/CreateProjectGroupModel.kt new file mode 100644 index 0000000000..62cf7391b5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createProject/CreateProjectGroupModel.kt @@ -0,0 +1,10 @@ +package io.tolgee.activity.groups.viewProviders.createProject + +import io.tolgee.activity.groups.baseModels.ActivityGroupLanguageModel + +class CreateProjectGroupModel( + val id: Long, + val name: String, + val languages: List, + val description: String?, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createProject/CreateProjectGroupModelProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createProject/CreateProjectGroupModelProvider.kt new file mode 100644 index 0000000000..12196f009f --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/createProject/CreateProjectGroupModelProvider.kt @@ -0,0 +1,72 @@ +package io.tolgee.activity.groups.viewProviders.createProject + +import io.tolgee.activity.groups.GroupModelProvider +import io.tolgee.activity.groups.baseModels.ActivityGroupLanguageModel +import org.jooq.DSLContext +import org.jooq.impl.DSL +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Component + +@Component +class CreateProjectGroupModelProvider( + private val jooqContext: DSLContext, +) : + GroupModelProvider { + override fun provideGroup(groupIds: List): Map { + val query = + jooqContext + .select( + DSL.field("arag.activity_groups_id", Long::class.java).`as`("groupId"), + DSL.field("pme.modifications -> 'name' ->> 'new'", String::class.java).`as`("name"), + DSL.field("pme.modifications -> 'description' ->> 'new'", String::class.java).`as`("description"), + DSL.field("pme.entity_id").`as`("id"), + DSL.field("lme.modifications -> 'name' ->> 'new'", String::class.java).`as`("languageName"), + DSL.field("lme.entity_id", Long::class.java).`as`("languageId"), + DSL.field("lme.modifications -> 'tag' ->> 'new'", String::class.java).`as`("languageTag"), + DSL.field("lme.modifications -> 'originalName' ->> 'new'", String::class.java).`as`("languageOriginalName"), + DSL.field("lme.modifications -> 'flagEmoji' ->> 'new'", String::class.java).`as`("languageFlagEmoji"), + ) + .from(DSL.table("activity_modified_entity").`as`("pme")) + .join(DSL.table("activity_revision").`as`("ar")) + .on(DSL.field("pme.activity_revision_id").eq(DSL.field("ar.id"))) + .join(DSL.table("activity_revision_activity_groups").`as`("arag")) + .on(DSL.field("ar.id").eq(DSL.field("arag.activity_revisions_id"))) + .join(DSL.table("activity_modified_entity").`as`("lme")) + .on( + DSL.field("lme.entity_class").eq(DSL.inline("Language")) + .and(DSL.field("lme.activity_revision_id").eq(DSL.field("ar.id"))), + ) + .where(DSL.field("arag.activity_groups_id").`in`(groupIds)) + .and(DSL.field("pme.entity_class").eq(DSL.inline("Project"))) + .fetch() + + return query.groupBy { it["groupId"] as Long }.map { (id, rows) -> + val languages = + rows.map { + ActivityGroupLanguageModel( + it["languageId"] as Long, + it["languageName"] as String, + it["languageOriginalName"] as String, + it["languageTag"] as String, + it["languageFlagEmoji"] as String, + ) + } + + id to + CreateProjectGroupModel( + id = id, + name = rows.first()["name"] as String, + description = rows.first()["description"] as String?, + languages = languages, + ) + }.toMap() + } + + override fun provideItems( + groupId: Long, + pageable: Pageable, + ): Page { + return Page.empty() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/setTranslations/SetTranslationsGroupItemModel.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/setTranslations/SetTranslationsGroupItemModel.kt new file mode 100644 index 0000000000..806e82e1db --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/setTranslations/SetTranslationsGroupItemModel.kt @@ -0,0 +1,12 @@ +package io.tolgee.activity.groups.viewProviders.setTranslations + +import io.tolgee.activity.data.PropertyModification +import io.tolgee.activity.groups.baseModels.ActivityGroupKeyModel +import org.springframework.hateoas.server.core.Relation + +@Relation(collectionRelation = "items", itemRelation = "item") +class SetTranslationsGroupItemModel( + val id: Long, + val text: PropertyModification, + val key: ActivityGroupKeyModel, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/setTranslations/SetTranslationsGroupModel.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/setTranslations/SetTranslationsGroupModel.kt new file mode 100644 index 0000000000..22eb2a4ec7 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/setTranslations/SetTranslationsGroupModel.kt @@ -0,0 +1,5 @@ +package io.tolgee.activity.groups.viewProviders.setTranslations + +class SetTranslationsGroupModel( + val translationCount: Int, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/setTranslations/SetTranslationsGroupModelProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/setTranslations/SetTranslationsGroupModelProvider.kt new file mode 100644 index 0000000000..5057f4841d --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/groups/viewProviders/setTranslations/SetTranslationsGroupModelProvider.kt @@ -0,0 +1,37 @@ +package io.tolgee.activity.groups.viewProviders.setTranslations + +import io.tolgee.activity.groups.ActivityGroupType +import io.tolgee.activity.groups.GroupModelProvider +import io.tolgee.activity.groups.dataProviders.GroupDataProvider +import io.tolgee.model.translation.Translation +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Component + +@Component +class SetTranslationsGroupModelProvider( + private val groupDataProvider: GroupDataProvider, +) : + GroupModelProvider { + override fun provideGroup(groupIds: List): Map { + val translationCounts = + groupDataProvider.provideCounts( + ActivityGroupType.SET_TRANSLATIONS, + groupIds = groupIds, + entityClass = Translation::class, + ) + + return groupIds.associateWith { + SetTranslationsGroupModel( + translationCounts[it] ?: 0, + ) + } + } + + override fun provideItems( + groupId: Long, + pageable: Pageable, + ): Page { + return Page.empty() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/iterceptor/InterceptedEventsManager.kt b/backend/data/src/main/kotlin/io/tolgee/activity/iterceptor/InterceptedEventsManager.kt index 54dae5e62d..bd3f0cb08b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/activity/iterceptor/InterceptedEventsManager.kt +++ b/backend/data/src/main/kotlin/io/tolgee/activity/iterceptor/InterceptedEventsManager.kt @@ -31,6 +31,7 @@ import org.springframework.beans.factory.config.BeanDefinition.SCOPE_SINGLETON import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Scope import org.springframework.stereotype.Component +import java.util.IdentityHashMap import kotlin.reflect.KCallable import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.hasAnnotation @@ -116,7 +117,12 @@ class InterceptedEventsManager( val changesMap = getChangesMap(entity, currentState, previousState, propertyNames) - activityModifiedEntity.revisionType = revisionType + activityModifiedEntity.revisionType = + when { + // when we are replacing ADD with MOD, we want to keep ADD + activityModifiedEntity.revisionType == RevisionType.ADD && revisionType == RevisionType.MOD -> RevisionType.ADD + else -> revisionType + } activityModifiedEntity.modifications.putAll(changesMap) activityModifiedEntity.setEntityDescription(entity) @@ -150,9 +156,9 @@ class InterceptedEventsManager( ): ActivityModifiedEntity { val activityModifiedEntity = activityHolder.modifiedEntities - .computeIfAbsent(entity::class) { mutableMapOf() } + .computeIfAbsent(entity::class) { IdentityHashMap() } .computeIfAbsent( - entity.id, + entity, ) { ActivityModifiedEntity( activityRevision, @@ -275,7 +281,7 @@ class InterceptedEventsManager( revision.isInitializedByInterceptor = true revision.authorId = userAccount?.id try { - revision.projectId = projectHolder.project.id + revision.setProject(projectHolder.project) activityHolder.organizationId = projectHolder.project.organizationOwnerId } catch (e: ProjectNotSelectedException) { logger.debug("Project is not set in ProjectHolder. Activity will be stored without projectId.") diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/ActivityItemsParser.kt b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/ActivityItemsParser.kt new file mode 100644 index 0000000000..8c2fe40a76 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/ActivityItemsParser.kt @@ -0,0 +1,78 @@ +package io.tolgee.activity.rootActivity + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.springframework.stereotype.Component + +@Component +class ActivityItemsParser( + private val objectMapper: ObjectMapper, +) { + fun parse(items: List>): List { + return items.map { + parseItem(it) + } + } + + private fun parseItem(item: Array): ActivityTreeResultItem { + val modifications = item[2].parseJsonOrNull() + val type = Type.getByValue(item[4] as String) + return ActivityTreeResultItem( + entityClass = item[0] as String, + description = getDescription(modifications, item), + modifications = modifications, + entityId = item[3] as Long, + type = type, + parentId = getParentId(item), + ) + } + + private fun getParentId(item: Array): Long? { + return try { + item[5] as? Long + } catch (e: IndexOutOfBoundsException) { + null + } + } + + private fun getDescription( + modifications: Map?, + item: Array, + ): Map { + val description = item[1].parseJsonOrNull()?.toMutableMap() ?: mutableMapOf() + + val new = getNewFromModifications(modifications) ?: return description + + return description + new + } + + private fun getNewFromModifications(modifications: Map?): Map? { + return modifications?.map { + @Suppress("UNCHECKED_CAST") + it.key to ((it.value as? Map)?.get("new")) + }?.toMap() + } + + private fun Any?.parseJsonOrNull(): Map? { + if (this is String) { + return try { + objectMapper.readValue>(this) + } catch (e: Exception) { + null + } + } + return null + } + + enum class Type(val value: String) { + MODIFIED_ENTITY("AME"), + DESCRIBING_ENTITY("ADE"), + ; + + companion object { + fun getByValue(value: String): Type { + return entries.first { it.value == value } + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/ActivityTreeDefinitionItem.kt b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/ActivityTreeDefinitionItem.kt new file mode 100644 index 0000000000..5f57c7f977 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/ActivityTreeDefinitionItem.kt @@ -0,0 +1,19 @@ +package io.tolgee.activity.rootActivity + +import io.tolgee.model.EntityWithId +import kotlin.reflect.KClass + +/** + * In some cases we need to return the data in activities in structured way by some root entity, for example for import + * we need to return the data rooted by key, so we can list the data on front-end nicely. + */ +class ActivityTreeDefinitionItem( + val entityClass: KClass, + /** + * The field of the child entity that describes the parent entity. + * E.g., For `Translation` entity this would be the `key` + */ + val describingField: String? = null, + val single: Boolean = false, + val children: Map = emptyMap(), +) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/ActivityTreeResultItem.kt b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/ActivityTreeResultItem.kt new file mode 100644 index 0000000000..8b8da352a4 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/ActivityTreeResultItem.kt @@ -0,0 +1,11 @@ +package io.tolgee.activity.rootActivity + +data class ActivityTreeResultItem( + val entityClass: String, + val description: Map?, + val modifications: Map?, + val entityId: Long, + val type: ActivityItemsParser.Type, + val parentId: Long?, + var children: MutableMap? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/ChildItemsProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/ChildItemsProvider.kt new file mode 100644 index 0000000000..cd376acef3 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/ChildItemsProvider.kt @@ -0,0 +1,57 @@ +package io.tolgee.activity.rootActivity + +import jakarta.persistence.EntityManager +import org.springframework.context.ApplicationContext + +class ChildItemsProvider( + private val activityRevisionId: List, + private val treeItem: ActivityTreeDefinitionItem, + private val applicationContext: ApplicationContext, + private val parentIds: List, +) { + fun provide(): List { + val rootItems = getItemsRaw() + return itemsParser.parse(rootItems) + } + + private fun getItemsRaw(): List> { + return entityManager.createNativeQuery( + """ + select entity_class, ame.describing_data, modifications, ame.entity_id id, 'AME' as type, + (ame.describing_relations -> :describingField -> 'entityId')::bigint as parent_id + from activity_modified_entity ame + where ame.entity_class = :entityClass + and ame.activity_revision_id in :revisionIds + and (ame.describing_relations -> :describingField -> 'entityId')::bigint in :ids + union + select ade.entity_class, ade.data, null, ade.entity_id id, 'ADE' as type, + (ade.describing_relations -> :describingField -> 'entityId')::bigint as parent_id + from activity_describing_entity ade + where ade.activity_revision_id in :revisionIds + and ade.entity_class = :entityClass + and (ade.describing_relations -> :describingField -> 'entityId')::bigint in :ids + and ade.entity_id not in (select ame.entity_id id + from activity_modified_entity ame + where ame.activity_revision_id in :revisionIds + and ame.entity_class = :entityClass + and (ame.describing_relations -> :describingField -> 'entityId')::bigint in :ids + ) + order by id + """, + Array::class.java, + ) + .setParameter("entityClass", treeItem.entityClass.simpleName) + .setParameter("describingField", treeItem.describingField) + .setParameter("revisionIds", activityRevisionId) + .setParameter("ids", parentIds) + .resultList as List> + } + + val entityManager: EntityManager by lazy { + applicationContext.getBean(EntityManager::class.java) + } + + val itemsParser: ActivityItemsParser by lazy { + applicationContext.getBean(ActivityItemsParser::class.java) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/RootActivityProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/RootActivityProvider.kt new file mode 100644 index 0000000000..365b08216e --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/RootActivityProvider.kt @@ -0,0 +1,64 @@ +package io.tolgee.activity.rootActivity + +import io.tolgee.activity.groups.matchers.modifiedEntity.DefaultMatcher +import jakarta.persistence.EntityManager +import org.springframework.context.ApplicationContext +import org.springframework.data.domain.Pageable + +class RootActivityProvider( + private val applicationContext: ApplicationContext, + private val activityRevisionIds: List, + private val activityTreeDefinitionItem: ActivityTreeDefinitionItem, + private val pageable: Pageable, + filterModifications: List>? = null, +) { + private val filterModificationsByEntityClass = filterModifications?.groupBy { it.entityClass } + + private val rootItems by lazy { + val rootModificationItems = filterModificationsByEntityClass?.get(activityTreeDefinitionItem.entityClass) + + RootItemsProvider( + pageable, + activityRevisionIds, + activityTreeDefinitionItem.entityClass, + rootModificationItems, + applicationContext, + ).provide() + } + + fun provide(): List { + addChildren(activityTreeDefinitionItem, rootItems) + return rootItems + } + + private fun addChildren( + parentItemDefinition: ActivityTreeDefinitionItem, + parentItems: List, + ) { + val parentIds = parentItems.map { it.entityId } + parentItemDefinition.children.map { (key, item) -> + val childItems = + ChildItemsProvider(activityRevisionIds, item, applicationContext, parentIds).provide().groupBy { it.parentId } + + childItems.forEach { + addChildren(item, it.value) + } + + rootItems.forEach { parentItem -> + val children = + parentItem.children ?: let { + parentItem.children = mutableMapOf() + parentItem.children!! + } + + childItems[parentItem.entityId]?.let { + children[key] = if (item.single) it.singleOrNull() else it + } + } + } + } + + val entityManager: EntityManager by lazy { + applicationContext.getBean(EntityManager::class.java) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/RootItemsProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/RootItemsProvider.kt new file mode 100644 index 0000000000..685845d2b2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/RootItemsProvider.kt @@ -0,0 +1,89 @@ +package io.tolgee.activity.rootActivity + +import io.tolgee.activity.groups.matchers.modifiedEntity.DefaultMatcher +import io.tolgee.model.EntityWithId +import org.jooq.DSLContext +import org.jooq.impl.DSL.field +import org.jooq.impl.DSL.select +import org.jooq.impl.DSL.table +import org.jooq.impl.DSL.value +import org.springframework.context.ApplicationContext +import org.springframework.data.domain.Pageable +import kotlin.reflect.KClass + +class RootItemsProvider( + private val pageable: Pageable, + private val activityRevisionIds: List, + private val rootEntityClass: KClass, + rootModificationItems: List>?, + private val applicationContext: ApplicationContext, +) { + fun provide(): List { + val rootItems = getRootItemsRaw() + return itemsParser.parse(rootItems) + } + + fun getRootItemsRaw(): List> { + val limit = pageable.pageSize + val offset = pageable.offset + + val activityModifiedEntity = table("activity_modified_entity") + val activityDescribingEntity = table("activity_describing_entity") + + val ameEntityClass = field("entity_class", String::class.java) + val ameDescribingData = field("describing_data", Any::class.java) // Adjust type if needed + val ameModifications = field("modifications", Any::class.java) // Adjust type if needed + val ameEntityId = field("entity_id", Long::class.java) + val ameActivityRevisionId = field("activity_revision_id", Long::class.java) + + val adeEntityClass = field("entity_class", String::class.java) + val adeData = field("data", Any::class.java) // Adjust type if needed + val adeEntityId = field("entity_id", Long::class.java) + val adeActivityRevisionId = field("activity_revision_id", Long::class.java) + + val ameSelect = + select( + ameEntityClass, + ameDescribingData, + ameModifications, + ameEntityId.`as`("id"), + value("AME").`as`("type"), + ).from(activityModifiedEntity) + .where(ameEntityClass.eq(rootEntityClass.simpleName)) + .and(ameActivityRevisionId.`in`(activityRevisionIds)) + + val adeSubQuery = + select(ameEntityId) + .from(activityModifiedEntity) + .where(ameActivityRevisionId.`in`(activityRevisionIds)) + .and(ameEntityClass.eq(rootEntityClass.simpleName)) + + val adeSelect = + select( + adeEntityClass, + adeData, + value(null as Any?).`as`("modifications"), // Adjust type if needed + adeEntityId.`as`("id"), + value("ADE").`as`("type"), + ).from(activityDescribingEntity) + .where(adeActivityRevisionId.`in`(activityRevisionIds)) + .and(adeEntityClass.eq(rootEntityClass.simpleName)) + .and(adeEntityId.notIn(adeSubQuery)) + + val unionQuery = + ameSelect.unionAll(adeSelect) + .orderBy(field("id")) + .limit(limit) + .offset(offset) + + return jooqContext.fetch(unionQuery).map { it.intoArray() }.toList() + } + + private val itemsParser: ActivityItemsParser by lazy { + applicationContext.getBean(ActivityItemsParser::class.java) + } + + private val jooqContext: DSLContext by lazy { + applicationContext.getBean(DSLContext::class.java) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/keyRootActivityTree.kt b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/keyRootActivityTree.kt new file mode 100644 index 0000000000..4793568487 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/rootActivity/keyRootActivityTree.kt @@ -0,0 +1,23 @@ +package io.tolgee.activity.rootActivity + +import io.tolgee.model.key.Key +import io.tolgee.model.key.KeyMeta +import io.tolgee.model.translation.Translation + +val KeyActivityTreeDefinitionItem = + ActivityTreeDefinitionItem( + entityClass = Key::class, + children = + mapOf( + "translations" to + ActivityTreeDefinitionItem( + describingField = "key", + entityClass = Translation::class, + ), + "keyMeta" to + ActivityTreeDefinitionItem( + describingField = "key", + entityClass = KeyMeta::class, + ), + ), + ) diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/views/ModifiedEntitiesViewProvider.kt b/backend/data/src/main/kotlin/io/tolgee/activity/views/ModifiedEntitiesViewProvider.kt new file mode 100644 index 0000000000..334e3c5f89 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/views/ModifiedEntitiesViewProvider.kt @@ -0,0 +1,153 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.activity.views + +import io.sentry.Sentry +import io.tolgee.activity.annotation.ActivityReturnsExistence +import io.tolgee.activity.data.ActivityType +import io.tolgee.activity.data.EntityDescriptionRef +import io.tolgee.activity.data.ExistenceEntityDescription +import io.tolgee.model.activity.ActivityDescribingEntity +import io.tolgee.model.activity.ActivityModifiedEntity +import io.tolgee.model.views.activity.ModifiedEntityView +import io.tolgee.model.views.activity.SimpleModifiedEntityView +import io.tolgee.repository.activity.ActivityRevisionRepository +import io.tolgee.service.security.UserAccountService +import io.tolgee.util.EntityUtil +import jakarta.persistence.EntityManager +import org.springframework.context.ApplicationContext + +class ModifiedEntitiesViewProvider( + applicationContext: ApplicationContext, + private val modifiedEntities: Collection, +) { + val userAccountService: UserAccountService = + applicationContext.getBean(UserAccountService::class.java) + + private val activityRevisionRepository: ActivityRevisionRepository = + applicationContext.getBean(ActivityRevisionRepository::class.java) + + private val entityManager: EntityManager = + applicationContext.getBean(EntityManager::class.java) + + private val entityUtil: EntityUtil = + applicationContext.getBean(EntityUtil::class.java) + + private val describingEntities: Map> by lazy { fetchAllowedRevisionRelations() } + + private val entityExistences: Map, Boolean> by lazy { fetchEntityExistences() } + + fun get(): List { + return modifiedEntities.map entities@{ entity -> + val relations = getRelations(entity) + ModifiedEntityView( + entityClass = entity.entityClass, + entityId = entity.entityId, + exists = entityExistences[entity.entityClass to entity.entityId], + modifications = entity.modifications, + description = entity.describingData, + describingRelations = relations, + ) + } + } + + fun getSimple(): List { + return modifiedEntities.map entities@{ entity -> + val relations = getRelations(entity) + SimpleModifiedEntityView( + entityClass = entity.entityClass, + entityId = entity.entityId, + exists = entityExistences[entity.entityClass to entity.entityId], + modifications = entity.modifications, + description = entity.describingData, + describingRelations = relations, + ) + } + } + + private fun getRelations(entity: ActivityModifiedEntity): Map? { + return entity.describingRelations + ?.mapNotNull { + Pair( + it.key, + extractCompressedRef( + it.value, + describingEntities[entity.activityRevision.id] ?: let { _ -> + Sentry.captureException( + IllegalStateException("No relation data for revision ${entity.activityRevision.id}"), + ) + return@mapNotNull null + }, + ), + ) + } + ?.toMap() + } + + private fun fetchAllowedRevisionRelations(): Map> { + val revisionIds = modifiedEntities.map { it.activityRevision.id } + val allowedTypes = ActivityType.entries.filter { !it.onlyCountsInList } + return activityRevisionRepository.getRelationsForRevisions(revisionIds, allowedTypes) + .groupBy { it.activityRevision.id } + } + + private fun fetchEntityExistences(): Map, Boolean> { + val modifiedEntityClassIdPairs = modifiedEntities.map { it.entityClass to it.entityId } + val relationsClassIdPairs = describingEntities.flatMap { (_, data) -> data.map { it.entityClass to it.entityId } } + val entities = (modifiedEntityClassIdPairs + relationsClassIdPairs).toHashSet() + + return entities + .groupBy { (entityClass, _) -> entityClass } + .mapNotNull { (entityClassName, classIdPairs) -> + val entityClass = entityUtil.getRealEntityClass(entityClassName) + val annotation = entityClass?.getAnnotation(ActivityReturnsExistence::class.java) + if (annotation != null) { + val cb = entityManager.criteriaBuilder + val query = cb.createQuery(Long::class.java) + val root = query.from(entityClass) + val ids = classIdPairs.map { it.second } + query.select(root.get("id")) + query.where(root.get("id").`in`(ids)) + val existingIds = entityManager.createQuery(query).resultList + return@mapNotNull (entityClassName to ids.map { it to existingIds.contains(it) }) + } + return@mapNotNull null + } + .flatMap { (entityClassName, existingIds) -> existingIds.map { (entityClassName to it.first) to it.second } } + .toMap() + } + + private fun extractCompressedRef( + value: EntityDescriptionRef, + describingEntities: List, + ): ExistenceEntityDescription { + val entity = describingEntities.find { it.entityClass == value.entityClass && it.entityId == value.entityId } + + val relations = + entity?.describingRelations + ?.map { it.key to extractCompressedRef(it.value, describingEntities) } + ?.toMap() + + return ExistenceEntityDescription( + entityClass = value.entityClass, + entityId = value.entityId, + exists = entityExistences[value.entityClass to value.entityId], + data = entity?.data ?: mapOf(), + relations = relations ?: mapOf(), + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/api/IKeyModel.kt b/backend/data/src/main/kotlin/io/tolgee/api/IKeyModel.kt new file mode 100644 index 0000000000..5a19fc77b2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/api/IKeyModel.kt @@ -0,0 +1,23 @@ +package io.tolgee.api + +import io.swagger.v3.oas.annotations.media.Schema + +interface IKeyModel { + @get:Schema(description = "Id of key record") + val id: Long + + @get:Schema(description = "Name of key", example = "this_is_super_key") + val name: String + + @get:Schema(description = "Namespace of key", example = "homepage") + val namespace: String? + + @get:Schema( + description = "Description of key", + example = "This key is used on homepage. It's a label of sign up button.", + ) + val description: String? + + @get:Schema(description = "Custom values of the key") + val custom: Map? +} diff --git a/backend/data/src/main/kotlin/io/tolgee/api/ILanguageModel.kt b/backend/data/src/main/kotlin/io/tolgee/api/ILanguageModel.kt new file mode 100644 index 0000000000..5c377a7e1a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/api/ILanguageModel.kt @@ -0,0 +1,9 @@ +package io.tolgee.api + +interface ILanguageModel { + val id: Long + val name: String + val tag: String + val originalName: String? + val flagEmoji: String? +} diff --git a/backend/data/src/main/kotlin/io/tolgee/api/ProjectIdAndBaseLanguageId.kt b/backend/data/src/main/kotlin/io/tolgee/api/ProjectIdAndBaseLanguageId.kt new file mode 100644 index 0000000000..8d045c0095 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/api/ProjectIdAndBaseLanguageId.kt @@ -0,0 +1,6 @@ +package io.tolgee.api + +interface ProjectIdAndBaseLanguageId { + val id: Long + val baseLanguageId: Long? +} diff --git a/backend/data/src/main/kotlin/io/tolgee/api/SimpleUserAccount.kt b/backend/data/src/main/kotlin/io/tolgee/api/SimpleUserAccount.kt new file mode 100644 index 0000000000..a07e1a2200 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/api/SimpleUserAccount.kt @@ -0,0 +1,9 @@ +package io.tolgee.api + +interface SimpleUserAccount { + val id: Long + val username: String + val name: String + val deleted: Boolean + val avatarHash: String? +} diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobActionService.kt b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobActionService.kt index 168712a90f..61ac9bcca1 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobActionService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobActionService.kt @@ -55,7 +55,6 @@ class BatchJobActionService( @EventListener(ApplicationReadyEvent::class) fun run() { - println("Application ready") concurrentExecutionLauncher.run { executionItem, coroutineContext -> var retryExecution: BatchJobChunkExecution? = null try { diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessingUtil.kt b/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessingUtil.kt index 5346568876..6e3cbf96ac 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessingUtil.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessingUtil.kt @@ -10,6 +10,7 @@ import io.tolgee.exceptions.OutOfCreditsException import io.tolgee.model.batch.BatchJob import io.tolgee.model.batch.BatchJobChunkExecution import io.tolgee.model.batch.BatchJobChunkExecutionStatus +import io.tolgee.service.project.ProjectService import io.tolgee.util.Logging import io.tolgee.util.logger import jakarta.persistence.EntityManager @@ -53,7 +54,8 @@ open class ChunkProcessingUtil( val activityRevision = activityHolder.activityRevision activityRevision.batchJobChunkExecution = execution val batchJobDto = batchJobService.getJobDto(job.id) - activityRevision.projectId = batchJobDto.projectId + val project = projectService.get(batchJobDto.projectId) + activityRevision.setProject(project) activityHolder.activity = batchJobDto.type.activityType activityRevision.authorId = batchJobDto.authorId } @@ -150,6 +152,10 @@ open class ChunkProcessingUtil( batchJobService.getProcessor(job.type) } + private val projectService: ProjectService by lazy { + applicationContext.getBean(ProjectService::class.java) + } + private var successfulTargets: List? = null private val toProcess by lazy { diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/ProgressManager.kt b/backend/data/src/main/kotlin/io/tolgee/batch/ProgressManager.kt index f712347cc0..cc27c24c93 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/ProgressManager.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/ProgressManager.kt @@ -4,7 +4,7 @@ import io.tolgee.batch.data.BatchJobDto import io.tolgee.batch.events.OnBatchJobCancelled import io.tolgee.batch.events.OnBatchJobFailed import io.tolgee.batch.events.OnBatchJobProgress -import io.tolgee.batch.events.OnBatchJobStatusUpdated +import io.tolgee.batch.events.OnBatchJobStarted import io.tolgee.batch.events.OnBatchJobSucceeded import io.tolgee.batch.state.BatchJobStateProvider import io.tolgee.batch.state.ExecutionState @@ -130,7 +130,6 @@ class ProgressManager( } fun onJobCompletedCommitted(batchJob: BatchJobDto) { - eventPublisher.publishEvent(OnBatchJobStatusUpdated(batchJob.id, batchJob.projectId, batchJob.status)) cachingBatchJobService.evictJobCache(batchJob.id) batchJobProjectLockingManager.unlockJobForProject(batchJob.projectId, batchJob.id) batchJobStateProvider.removeJobState(batchJob.id) @@ -200,6 +199,7 @@ class ProgressManager( if (job.status == BatchJobStatus.PENDING) { logger.debug { """Updating job state to running ${job.id}""" } cachingBatchJobService.setRunningState(job.id) + eventPublisher.publishEvent(OnBatchJobStarted(job)) } } } diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/events/OnBatchJobFinalized.kt b/backend/data/src/main/kotlin/io/tolgee/batch/events/OnBatchJobFinalized.kt deleted file mode 100644 index f73554f866..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/batch/events/OnBatchJobFinalized.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.tolgee.batch.events - -import io.tolgee.batch.OnBatchJobCompleted -import io.tolgee.batch.data.BatchJobDto - -data class OnBatchJobFinalized( - override val job: BatchJobDto, -) : OnBatchJobCompleted diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/events/OnBatchJobStarted.kt b/backend/data/src/main/kotlin/io/tolgee/batch/events/OnBatchJobStarted.kt new file mode 100644 index 0000000000..b12f8a0443 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/batch/events/OnBatchJobStarted.kt @@ -0,0 +1,21 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.batch.events + +import io.tolgee.batch.data.BatchJobDto + +data class OnBatchJobStarted(val job: BatchJobDto) diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/events/OnBatchJobStatusUpdated.kt b/backend/data/src/main/kotlin/io/tolgee/batch/events/OnBatchJobStatusUpdated.kt deleted file mode 100644 index 1ff05fde54..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/batch/events/OnBatchJobStatusUpdated.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.tolgee.batch.events - -import io.tolgee.model.batch.BatchJobStatus - -class OnBatchJobStatusUpdated( - val jobId: Long, - val projectId: Long, - val status: BatchJobStatus, -) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookProcessor.kt index f6a9b5e731..7da09f98e0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookProcessor.kt @@ -26,7 +26,8 @@ class WebhookProcessor( activityRevisionId: Long?, ) { activityRevisionId ?: return - val view = activityService.findProjectActivity(activityRevisionId) ?: return + val projectId = action.automation.project.id + val view = activityService.getProjectActivity(projectId = projectId, revisionId = activityRevisionId) ?: return val activityModel = activityModelAssembler.toModel(view) val config = action.webhookConfig ?: return diff --git a/backend/data/src/main/kotlin/io/tolgee/component/demoProject/DemoProjectCreator.kt b/backend/data/src/main/kotlin/io/tolgee/component/demoProject/DemoProjectCreator.kt index 6569eb2ca5..0beeab7666 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/demoProject/DemoProjectCreator.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/demoProject/DemoProjectCreator.kt @@ -39,7 +39,7 @@ class DemoProjectCreator( fun createDemoProject(): Project { activityHolder.activity = ActivityType.CREATE_PROJECT - activityHolder.activityRevision.projectId = project.id + activityHolder.activityRevision.setProject(project) setStates() addBigMeta() addScreenshots() diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt index 5425b46918..c4728238f6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt @@ -112,6 +112,14 @@ class TestDataService( executeInNewTransaction(transactionManager) { saveProjectData(builder) + + // These depend on users and projects, so they must be stored only after all the projects have been stored. + builder.data.userAccounts.forEach { + it.data.notificationPreferences.forEach { entityBuilder -> + entityManager.persist(entityBuilder.self) + } + } + finalize() } @@ -375,7 +383,7 @@ class TestDataService( } private fun saveAllKeyDependants(keyBuilders: List) { - val metas = keyBuilders.map { it.data.meta?.self }.filterNotNull() + val metas = keyBuilders.mapNotNull { it.data.meta?.self } tagService.saveAll(metas.flatMap { it.tags }) keyMetaService.saveAll(metas) } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/NotificationPreferencesBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/NotificationPreferencesBuilder.kt new file mode 100644 index 0000000000..d9281301aa --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/NotificationPreferencesBuilder.kt @@ -0,0 +1,11 @@ +package io.tolgee.development.testDataBuilder.builders + +import io.tolgee.development.testDataBuilder.EntityDataBuilder +import io.tolgee.model.notifications.NotificationPreferences + +class NotificationPreferencesBuilder( + val userAccountBuilder: UserAccountBuilder, +) : EntityDataBuilder { + override var self: NotificationPreferences = + NotificationPreferences(userAccountBuilder.self, null, emptyArray()) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/UserAccountBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/UserAccountBuilder.kt index efe470e20a..9abe90c3d7 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/UserAccountBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/UserAccountBuilder.kt @@ -5,6 +5,7 @@ import io.tolgee.development.testDataBuilder.builders.slack.SlackUserConnectionB import io.tolgee.model.Pat import io.tolgee.model.UserAccount import io.tolgee.model.UserPreferences +import io.tolgee.model.notifications.NotificationPreferences import io.tolgee.model.slackIntegration.SlackUserConnection import org.springframework.core.io.ClassPathResource @@ -20,6 +21,7 @@ class UserAccountBuilder( var userPreferences: UserPreferencesBuilder? = null var pats: MutableList = mutableListOf() var slackUserConnections: MutableList = mutableListOf() + var notificationPreferences: MutableList = mutableListOf() } var data = DATA() @@ -37,4 +39,6 @@ class UserAccountBuilder( fun addPat(ft: FT) = addOperation(data.pats, ft) fun addSlackUserConnection(ft: FT) = addOperation(data.slackUserConnections, ft) + + fun addNotificationPreferences(ft: FT) = addOperation(data.notificationPreferences, ft) } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NotificationSubscriptionTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NotificationSubscriptionTestData.kt new file mode 100644 index 0000000000..5210ae8307 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NotificationSubscriptionTestData.kt @@ -0,0 +1,109 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.model.Organization +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.notifications.NotificationType + +class NotificationSubscriptionTestData { + lateinit var user1: UserAccount + lateinit var user2: UserAccount + + lateinit var organization: Organization + + lateinit var project1: Project + lateinit var project2: Project + + val root: TestDataBuilder = TestDataBuilder() + + init { + root.apply { + addUserAccount { + username = "admin" + role = UserAccount.Role.ADMIN + } + + val user1Builder = + addUserAccountWithoutOrganization { + name = "User 1" + username = "user1" + user1 = this + } + + val user2Builder = + addUserAccountWithoutOrganization { + name = "User 2" + username = "user2" + user2 = this + } + + addOrganization { + name = "Test org" + slug = "test-org" + + organization = this + + addProject { + name = "Test project 1" + slug = "project1" + organizationOwner = organization + + project1 = this + } + + addProject { + name = "Test project 2" + slug = "project2" + organizationOwner = organization + + project2 = this + } + }.build { + addRole { + user = user1 + type = OrganizationRoleType.OWNER + } + + addRole { + user = user2 + type = OrganizationRoleType.OWNER + } + } + + user1Builder.build { + addNotificationPreferences { + project = project2 + disabledNotifications = arrayOf(NotificationType.ACTIVITY_KEYS_CREATED) + } + } + + user2Builder.build { + addNotificationPreferences { + disabledNotifications = arrayOf(NotificationType.ACTIVITY_KEYS_CREATED) + } + + addNotificationPreferences { + project = project2 + } + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NotificationsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NotificationsTestData.kt new file mode 100644 index 0000000000..833e5f2df2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NotificationsTestData.kt @@ -0,0 +1,331 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.model.Language +import io.tolgee.model.Organization +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.model.enums.ProjectPermissionType +import io.tolgee.model.enums.Scope +import io.tolgee.model.enums.TranslationState +import io.tolgee.model.key.Key +import io.tolgee.model.translation.Translation + +class NotificationsTestData { + lateinit var admin: UserAccount + + lateinit var orgAdmin: UserAccount + lateinit var projectManager: UserAccount + lateinit var frenchTranslator: UserAccount + lateinit var czechTranslator: UserAccount + lateinit var germanTranslator: UserAccount + lateinit var frenchCzechTranslator: UserAccount + + lateinit var bob: UserAccount + lateinit var alice: UserAccount + + lateinit var acme: Organization + + lateinit var project1: Project + lateinit var project2: Project + lateinit var calmProject: Project // A project with only Alice in it for less notification noise + + lateinit var keyProject1: Key + lateinit var key1EnTranslation: Translation + lateinit var key1FrTranslation: Translation + lateinit var key1CzTranslation: Translation + + lateinit var keyProject2: Key + lateinit var key2EnTranslation: Translation + + lateinit var keyCalmProject: Key + lateinit var keyCalmEnTranslation: Translation + + lateinit var calmProjectFr: Language + + val root: TestDataBuilder = TestDataBuilder() + + init { + root.apply { + addUserAccount { + name = "Admin" + username = "admin" + isInitialUser = true + role = UserAccount.Role.ADMIN + + admin = this + } + + addUserAccountWithoutOrganization { + name = "Acme 1 chief" + username = "chief-acme-1" + + orgAdmin = this + } + + addUserAccountWithoutOrganization { + name = "Project manager" + username = "project-manager" + + projectManager = this + } + + addUserAccountWithoutOrganization { + name = "Translator (french)" + username = "french-translator" + + frenchTranslator = this + } + + addUserAccountWithoutOrganization { + name = "Translator (czech)" + username = "czech-translator" + + czechTranslator = this + } + + addUserAccountWithoutOrganization { + name = "Translator (german)" + username = "german-translator" + + germanTranslator = this + } + + addUserAccountWithoutOrganization { + name = "Translator (french and czech)" + username = "french-czech-translator" + + frenchCzechTranslator = this + } + + addUserAccount { + name = "Bob" + username = "bob" + + bob = this + } + + addUserAccount { + name = "Alice" + username = "alice" + + alice = this + }.build { + defaultOrganizationBuilder.let { + addProject { + name = "Calm project" + slug = "keep-it-cool" + organizationOwner = it.self + + calmProject = this + }.build { + val en = addEnglish() + calmProjectFr = addFrench().self + + val key = + addKey { + name = "some-key" + + this@NotificationsTestData.keyCalmProject = this@addKey + } + + addTranslation { + this.key = key.self + language = en.self + text = "Some translation" + + this@NotificationsTestData.keyCalmEnTranslation = this@addTranslation + } + } + } + } + + addOrganization { + name = "ACME Corporation" + slug = "acme" + + this@NotificationsTestData.acme = this@addOrganization + }.build { + addRole { + user = orgAdmin + type = OrganizationRoleType.OWNER + } + + addRole { + user = projectManager + type = OrganizationRoleType.MEMBER + } + + addRole { + user = frenchTranslator + type = OrganizationRoleType.MEMBER + } + + addRole { + user = czechTranslator + type = OrganizationRoleType.MEMBER + } + + addRole { + user = germanTranslator + type = OrganizationRoleType.MEMBER + } + + addRole { + user = frenchCzechTranslator + type = OrganizationRoleType.MEMBER + } + + addProject { + name = "Explosive Type-Checker" + slug = "explosive-type-checker" + organizationOwner = acme + + project1 = this + }.build { + val en = addEnglish() + val fr = addFrench() + val cz = addCzech() + val de = addGerman() + + val key = + addKey { + name = "some-key" + + this@NotificationsTestData.keyProject1 = this@addKey + } + + addTranslation { + this.key = key.self + language = en.self + text = "Some translation" + state = TranslationState.REVIEWED + + this@NotificationsTestData.key1EnTranslation = this@addTranslation + } + + addTranslation { + this.key = key.self + language = fr.self + text = "Some french translation" + state = TranslationState.REVIEWED + + this@NotificationsTestData.key1FrTranslation = this@addTranslation + } + + addTranslation { + this.key = key.self + language = cz.self + text = "Some czech translation" + + this@NotificationsTestData.key1CzTranslation = this@addTranslation + } + + // --- --- --- + addPermission { + user = projectManager + type = ProjectPermissionType.MANAGE + } + + addPermission { + user = frenchTranslator + type = ProjectPermissionType.EDIT + viewLanguages.add(en.self) + viewLanguages.add(fr.self) + translateLanguages.add(en.self) + translateLanguages.add(fr.self) + stateChangeLanguages.add(en.self) + stateChangeLanguages.add(fr.self) + } + + addPermission { + user = czechTranslator + type = ProjectPermissionType.EDIT + viewLanguages.add(en.self) + viewLanguages.add(cz.self) + translateLanguages.add(en.self) + translateLanguages.add(cz.self) + stateChangeLanguages.add(en.self) + stateChangeLanguages.add(cz.self) + } + + addPermission { + user = germanTranslator + type = ProjectPermissionType.EDIT + viewLanguages.add(en.self) + viewLanguages.add(de.self) + translateLanguages.add(en.self) + translateLanguages.add(de.self) + stateChangeLanguages.add(en.self) + stateChangeLanguages.add(de.self) + } + + addPermission { + user = frenchCzechTranslator + type = ProjectPermissionType.EDIT + viewLanguages.add(en.self) + viewLanguages.add(fr.self) + viewLanguages.add(cz.self) + translateLanguages.add(en.self) + translateLanguages.add(fr.self) + translateLanguages.add(cz.self) + stateChangeLanguages.add(en.self) + stateChangeLanguages.add(fr.self) + stateChangeLanguages.add(cz.self) + } + + addPermission { + user = bob + scopes = arrayOf(Scope.TRANSLATIONS_EDIT) + } + } + + addProject { + name = "Rocket-Powered Office Chair Controller" + slug = "rpocc" + organizationOwner = acme + + project2 = this + }.build { + val en = addEnglish() + val key = + addKey { + name = "some-key" + + this@NotificationsTestData.keyProject2 = this@addKey + } + + addTranslation { + this.key = key.self + language = en.self + text = "Some translation" + + this@NotificationsTestData.key2EnTranslation = this@addTranslation + } + + addPermission { + user = alice + scopes = arrayOf(Scope.TRANSLATIONS_EDIT, Scope.SCREENSHOTS_UPLOAD) + } + } + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportTestData.kt index d448c70c5b..a204b39c64 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportTestData.kt @@ -343,6 +343,7 @@ class ImportTestData { line = 10 fromImport = true } + description = "This is a key" } projectBuilder.data.imports[0].data.importFiles[0].data.importKeys[2].addMeta { diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt index 8c75654358..107e6cda4c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt @@ -77,9 +77,9 @@ class ComputedPermissionDto( ) companion object { - private fun getEmptyPermission( + fun getEmptyPermission( scopes: Array, - type: ProjectPermissionType, + type: ProjectPermissionType?, ): IPermission { return object : IPermission { override val scopes: Array @@ -94,7 +94,7 @@ class ComputedPermissionDto( get() = null override val stateChangeLanguageIds: Set? get() = null - override val type: ProjectPermissionType + override val type: ProjectPermissionType? get() = type override val granular: Boolean? get() = null diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt index ae9d65fc26..78387e8362 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt @@ -1,6 +1,7 @@ package io.tolgee.dtos.cacheable import io.tolgee.api.ISimpleProject +import io.tolgee.api.ProjectIdAndBaseLanguageId import io.tolgee.model.Project import java.io.Serializable @@ -13,7 +14,8 @@ data class ProjectDto( var aiTranslatorPromptDescription: String?, override var avatarHash: String? = null, override var icuPlaceholders: Boolean, -) : Serializable, ISimpleProject { + override val baseLanguageId: Long?, +) : Serializable, ISimpleProject, ProjectIdAndBaseLanguageId { companion object { fun fromEntity(entity: Project) = ProjectDto( @@ -25,6 +27,7 @@ data class ProjectDto( aiTranslatorPromptDescription = entity.aiTranslatorPromptDescription, avatarHash = entity.avatarHash, icuPlaceholders = entity.icuPlaceholders, + baseLanguageId = entity.baseLanguageId, ) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/ActivityGroupView.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/ActivityGroupView.kt new file mode 100644 index 0000000000..81a5b8b7d3 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/ActivityGroupView.kt @@ -0,0 +1,13 @@ +package io.tolgee.dtos.queryResults + +import io.tolgee.activity.groups.ActivityGroupType +import io.tolgee.api.SimpleUserAccount + +data class ActivityGroupView( + val id: Long, + val type: ActivityGroupType, + val timestamp: java.util.Date, + var data: Any? = null, + var author: SimpleUserAccount, + var mentionedLanguageIds: List, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/TranslationHistoryView.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/TranslationHistoryView.kt index f6722d2ab6..59110db247 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/TranslationHistoryView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/TranslationHistoryView.kt @@ -7,7 +7,7 @@ import java.util.* interface TranslationHistoryView { var modifications: Map? var timestamp: Date - var authorName: String? + var authorName: String var authorAvatarHash: String? var authorEmail: String? var authorDeletedAt: Date? diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/ActivityGroupFilters.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/ActivityGroupFilters.kt new file mode 100644 index 0000000000..66b8cb3c9d --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/ActivityGroupFilters.kt @@ -0,0 +1,9 @@ +package io.tolgee.dtos.request + +import io.tolgee.activity.groups.ActivityGroupType + +class ActivityGroupFilters { + var filterType: ActivityGroupType? = null + var filterLanguageIdIn: List? = null + var filterAuthorUserIdIn: List? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/CreateKeyDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/CreateKeyDto.kt index 5e6b6793b3..813fd852cd 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/CreateKeyDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/CreateKeyDto.kt @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.tolgee.dtos.RelatedKeyDto import io.tolgee.dtos.WithRelatedKeysInOrder import io.tolgee.model.enums.AssignableTranslationState +import io.tolgee.sharedDocs.Key import io.tolgee.util.getSafeNamespace import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Size @@ -40,7 +41,7 @@ class CreateKeyDto( example = "This key is used on homepage. It's a label of sign up button.", ) val description: String? = null, - @Schema(description = "If key is pluralized. If it will be reflected in the editor") + @Schema(description = Key.IS_PLURAL_FIELD) val isPlural: Boolean = false, @Schema( description = diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/EditKeyDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/EditKeyDto.kt index 6b47404816..e707c8bf66 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/EditKeyDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/EditKeyDto.kt @@ -14,7 +14,7 @@ data class EditKeyDto( @field:Length(max = 2000) var name: String = "", @field:Length(max = ValidationConstants.MAX_NAMESPACE_LENGTH) - @Schema(description = "The namespace of the key. (When empty or null default namespace will be used)") + @Schema(description = "The namespace of the key. (When empty or null, no namespace will be used)") @JsonProperty(access = JsonProperty.Access.READ_ONLY) var namespace: String? = null, @Size(max = 2000) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/KeyScreenshotDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/KeyScreenshotDto.kt index f890f042c5..878fdaed7b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/KeyScreenshotDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/KeyScreenshotDto.kt @@ -3,10 +3,9 @@ package io.tolgee.dtos.request.key import io.swagger.v3.oas.annotations.media.Schema import io.tolgee.dtos.request.KeyInScreenshotPositionDto -class KeyScreenshotDto { - var text: String? = null - +data class KeyScreenshotDto( + var text: String? = null, @Schema(description = "Ids of screenshot uploaded with /v2/image-upload endpoint") - var uploadedImageId: Long = 0 - var positions: List? = null -} + var uploadedImageId: Long = 0, + var positions: List? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/EmailVerification.kt b/backend/data/src/main/kotlin/io/tolgee/model/EmailVerification.kt index 3c0cd8b330..f975d38499 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/EmailVerification.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/EmailVerification.kt @@ -20,7 +20,6 @@ data class EmailVerification( @Email var newEmail: String? = null, ) : AuditModel() { - @Suppress("JoinDeclarationAndAssignment") @OneToOne(optional = false) lateinit var userAccount: UserAccount diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Permission.kt b/backend/data/src/main/kotlin/io/tolgee/model/Permission.kt index 97a950bb52..7041442e60 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Permission.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Permission.kt @@ -21,6 +21,7 @@ import jakarta.persistence.ManyToOne import jakarta.persistence.OneToOne import jakarta.persistence.PrePersist import jakarta.persistence.PreUpdate +import org.hibernate.Hibernate import org.hibernate.annotations.Parameter import org.hibernate.annotations.Type @@ -79,7 +80,7 @@ class Permission( * Languages for TRANSLATIONS_EDIT scope. * When specified, user is restricted to edit specific language translations. */ - @ManyToMany(fetch = FetchType.EAGER) + @ManyToMany(fetch = FetchType.LAZY) @JoinTable(name = "permission_languages", inverseJoinColumns = [JoinColumn(name = "languages_id")]) var translateLanguages: MutableSet = mutableSetOf() @@ -87,14 +88,14 @@ class Permission( * Languages for TRANSLATIONS_EDIT scope. * When specified, user is restricted to edit specific language translations. */ - @ManyToMany(fetch = FetchType.EAGER) + @ManyToMany(fetch = FetchType.LAZY) var viewLanguages: MutableSet = mutableSetOf() /** * Languages for TRANSLATIONS_EDIT scope. * When specified, user is restricted to edit specific language translations. */ - @ManyToMany(fetch = FetchType.EAGER) + @ManyToMany(fetch = FetchType.LAZY) var stateChangeLanguages: MutableSet = mutableSetOf() constructor( @@ -132,14 +133,42 @@ class Permission( get() = this.project?.id override val organizationId: Long? get() = this.organization?.id - override val translateLanguageIds: Set? - get() = this.translateLanguages.map { it.id }.toSet() + + @Transient + private var fetchedViewLanguageIds: Set? = null + + @Transient + private var fetchedTranslateLanguageIds: Set? = null + + @Transient + private var fetchedStateChangeLanguageIds: Set? = null override val viewLanguageIds: Set? - get() = this.viewLanguages.map { it.id }.toSet() + get() { + if (fetchedViewLanguageIds == null || Hibernate.isInitialized(this.viewLanguages)) { + return this.viewLanguages.map { it.id }.toSet() + } + + return fetchedViewLanguageIds + } + + override val translateLanguageIds: Set? + get() { + if (fetchedTranslateLanguageIds == null || Hibernate.isInitialized(this.translateLanguages)) { + return this.translateLanguages.map { it.id }.toSet() + } + + return fetchedTranslateLanguageIds + } override val stateChangeLanguageIds: Set? - get() = this.stateChangeLanguages.map { it.id }.toSet() + get() { + if (fetchedStateChangeLanguageIds == null || Hibernate.isInitialized(this.stateChangeLanguages)) { + return this.stateChangeLanguages.map { it.id }.toSet() + } + + return fetchedStateChangeLanguageIds + } companion object { class PermissionListeners { @@ -163,4 +192,31 @@ class Permission( } } } + + class PermissionWithLanguageIdsWrapper( + val permission: Permission, + fetchedViewLanguageIds: Any?, + fetchedTranslateLanguageIds: Any?, + fetchedStateChangeLanguageIds: Any?, + ) { + init { + permission.fetchedViewLanguageIds = (fetchedViewLanguageIds as? String) + ?.split(',') + ?.map { e -> e.toLong() } + ?.toSet() + ?: emptySet() + + permission.fetchedTranslateLanguageIds = (fetchedTranslateLanguageIds as? String) + ?.split(',') + ?.map { e -> e.toLong() } + ?.toSet() + ?: emptySet() + + permission.fetchedStateChangeLanguageIds = (fetchedStateChangeLanguageIds as? String) + ?.split(',') + ?.map { e -> e.toLong() } + ?.toSet() + ?: emptySet() + } + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Project.kt b/backend/data/src/main/kotlin/io/tolgee/model/Project.kt index af42a4cb99..cc9525b0af 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Project.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Project.kt @@ -3,6 +3,7 @@ package io.tolgee.model import io.tolgee.activity.annotation.ActivityLoggedEntity import io.tolgee.activity.annotation.ActivityLoggedProp import io.tolgee.api.ISimpleProject +import io.tolgee.api.ProjectIdAndBaseLanguageId import io.tolgee.exceptions.NotFoundException import io.tolgee.model.automations.Automation import io.tolgee.model.contentDelivery.ContentDeliveryConfig @@ -50,7 +51,7 @@ class Project( @field:Size(min = 3, max = 60) @field:Pattern(regexp = "^[a-z0-9-]*[a-z]+[a-z0-9-]*$", message = "invalid_pattern") override var slug: String? = null, -) : AuditModel(), ModelWithAvatar, EntityWithId, SoftDeletable, ISimpleProject { +) : AuditModel(), ModelWithAvatar, EntityWithId, SoftDeletable, ISimpleProject, ProjectIdAndBaseLanguageId { @OrderBy("id") @OneToMany(fetch = FetchType.LAZY, mappedBy = "project") var languages: MutableSet = LinkedHashSet() @@ -64,7 +65,6 @@ class Project( @OneToMany(fetch = FetchType.LAZY, mappedBy = "project") var apiKeys: MutableSet = LinkedHashSet() - @Suppress("SetterBackingFieldAssignment") @ManyToOne(optional = true, fetch = FetchType.LAZY) @Deprecated(message = "Project can be owned only by organization") var userOwner: UserAccount? = null @@ -96,16 +96,16 @@ class Project( @Column(insertable = false, updatable = false) override var disableActivityLogging = false - @OneToMany(orphanRemoval = true, mappedBy = "project") + @OneToMany(fetch = FetchType.LAZY, orphanRemoval = true, mappedBy = "project") var automations: MutableList = mutableListOf() - @OneToMany(orphanRemoval = true, mappedBy = "project") + @OneToMany(fetch = FetchType.LAZY, orphanRemoval = true, mappedBy = "project") var contentDeliveryConfigs: MutableList = mutableListOf() - @OneToMany(orphanRemoval = true, mappedBy = "project") + @OneToMany(fetch = FetchType.LAZY, orphanRemoval = true, mappedBy = "project") var contentStorages: MutableList = mutableListOf() - @OneToMany(orphanRemoval = true, mappedBy = "project") + @OneToMany(fetch = FetchType.LAZY, orphanRemoval = true, mappedBy = "project") var webhookConfigs: MutableList = mutableListOf() @OneToMany(fetch = FetchType.LAZY, orphanRemoval = true, mappedBy = "project") @@ -158,4 +158,7 @@ class Project( } } } + + override val baseLanguageId: Long? + get() = this.baseLanguage?.id } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt index a58bf9c674..10c37bc1a6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -2,6 +2,9 @@ package io.tolgee.model import io.hypersistence.utils.hibernate.type.array.ListArrayType import io.tolgee.api.IUserAccount +import io.tolgee.api.SimpleUserAccount +import io.tolgee.model.notifications.NotificationPreferences +import io.tolgee.model.notifications.UserNotification import io.tolgee.model.slackIntegration.SlackConfig import io.tolgee.model.slackIntegration.SlackUserConnection import jakarta.persistence.CascadeType @@ -19,6 +22,7 @@ import jakarta.persistence.OrderBy import jakarta.validation.constraints.NotBlank import org.hibernate.annotations.ColumnDefault import org.hibernate.annotations.Type +import org.hibernate.annotations.Where import java.util.* @Entity @@ -27,15 +31,15 @@ data class UserAccount( @GeneratedValue(strategy = GenerationType.IDENTITY) override var id: Long = 0L, @field:NotBlank - var username: String = "", + override var username: String = "", var password: String? = null, - var name: String = "", + override var name: String = "", @Enumerated(EnumType.STRING) var role: Role? = Role.USER, @Enumerated(EnumType.STRING) @Column(name = "account_type") override var accountType: AccountType? = AccountType.LOCAL, -) : AuditModel(), ModelWithAvatar, IUserAccount { +) : AuditModel(), ModelWithAvatar, IUserAccount, SimpleUserAccount { @Column(name = "totp_key", columnDefinition = "bytea") override var totpKey: ByteArray? = null @@ -102,6 +106,20 @@ data class UserAccount( @OneToMany(mappedBy = "userAccount", fetch = FetchType.LAZY, orphanRemoval = true) var slackConfig: MutableList = mutableListOf() + @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.REMOVE], orphanRemoval = true, mappedBy = "recipient") + var userNotifications: MutableList = mutableListOf() + + @Where(clause = "project_id IS NOT NULL") + @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.REMOVE], orphanRemoval = true, mappedBy = "userAccount") + var projectNotificationPreferences: MutableList = mutableListOf() + + @Where(clause = "project_id IS NULL") + @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.REMOVE], orphanRemoval = true, mappedBy = "userAccount") + private var _globalNotificationPreferences: MutableList = mutableListOf() + + override val deleted: Boolean + get() = deletedAt != null + constructor( id: Long?, username: String?, @@ -122,6 +140,9 @@ data class UserAccount( this.resetPasswordCode = resetPasswordCode } + val globalNotificationPreferences: NotificationPreferences? + get() = _globalNotificationPreferences.firstOrNull() + enum class Role { USER, ADMIN, diff --git a/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityDescribingEntity.kt b/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityDescribingEntity.kt index f2d28ac8f0..194371d173 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityDescribingEntity.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityDescribingEntity.kt @@ -23,12 +23,16 @@ class ActivityDescribingEntity( val entityClass: String, @Id val entityId: Long, -) : Serializable { +) : Serializable, ActivityEntityWithDescription { @Column(columnDefinition = "jsonb") @Type(JsonBinaryType::class) var data: Map = mutableMapOf() @Type(JsonBinaryType::class) @Column(columnDefinition = "jsonb") - var describingRelations: Map? = null + override var describingRelations: Map? = null + + @Column(columnDefinition = "jsonb") + @Type(JsonBinaryType::class) + override var additionalDescription: MutableMap? = null } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityEntityWithDescription.kt b/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityEntityWithDescription.kt new file mode 100644 index 0000000000..a4e4c08817 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityEntityWithDescription.kt @@ -0,0 +1,20 @@ +package io.tolgee.model.activity + +import io.tolgee.activity.data.EntityDescriptionRef + +interface ActivityEntityWithDescription { + var describingRelations: Map? + + /** + * This field is filled by components implementing [io.tolgee.activity.ActivityAdditionalDescriber] + */ + var additionalDescription: MutableMap? + + fun initAdditionalDescription(): MutableMap { + return additionalDescription ?: let { + val value = mutableMapOf() + additionalDescription = value + value + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityGroup.kt b/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityGroup.kt new file mode 100644 index 0000000000..f65f165112 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityGroup.kt @@ -0,0 +1,45 @@ +package io.tolgee.model.activity + +import io.tolgee.activity.groups.ActivityGroupType +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.ManyToMany +import jakarta.persistence.SequenceGenerator + +@Entity +class ActivityGroup( + @Enumerated(EnumType.STRING) + var type: ActivityGroupType, +) { + @Id + @SequenceGenerator( + name = "activitySequenceGenerator", + sequenceName = "activity_sequence", + initialValue = 0, + allocationSize = 10, + ) + @GeneratedValue( + strategy = GenerationType.SEQUENCE, + generator = "activitySequenceGenerator", + ) + val id: Long = 0 + + /** + * We don't want a foreign key, since user could have been deleted + */ + var authorId: Long? = null + + /** + * Project of the change + */ + var projectId: Long? = null + + @ManyToMany(mappedBy = "activityGroups") + var activityRevisions: MutableList = mutableListOf() + + var matchingString: String? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityModifiedEntity.kt b/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityModifiedEntity.kt index 93d5a096d5..fa776a4503 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityModifiedEntity.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityModifiedEntity.kt @@ -33,7 +33,7 @@ class ActivityModifiedEntity( */ @Id val entityId: Long, -) : Serializable { +) : Serializable, ActivityEntityWithDescription { /** * Map of field to object containing old and new values */ @@ -42,7 +42,7 @@ class ActivityModifiedEntity( var modifications: MutableMap = mutableMapOf() /** - * Data, which are discribing the entity, but are not modified by the change + * Data, which are describing the entity, but are not modified by the change */ @Column(columnDefinition = "jsonb") @Type(JsonBinaryType::class) @@ -54,7 +54,11 @@ class ActivityModifiedEntity( */ @Column(columnDefinition = "jsonb") @Type(JsonBinaryType::class) - var describingRelations: Map? = null + override var describingRelations: Map? = null + + @Column(columnDefinition = "jsonb") + @Type(JsonBinaryType::class) + override var additionalDescription: MutableMap? = null @Enumerated var revisionType: RevisionType = RevisionType.MOD diff --git a/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityRevision.kt b/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityRevision.kt index 4daa00567a..ec74a7b4f1 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityRevision.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityRevision.kt @@ -2,6 +2,7 @@ package io.tolgee.model.activity import io.hypersistence.utils.hibernate.type.json.JsonBinaryType import io.tolgee.activity.data.ActivityType +import io.tolgee.api.ProjectIdAndBaseLanguageId import io.tolgee.component.CurrentDateProvider import io.tolgee.model.batch.BatchJob import io.tolgee.model.batch.BatchJobChunkExecution @@ -15,6 +16,7 @@ import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.Index +import jakarta.persistence.ManyToMany import jakarta.persistence.OneToMany import jakarta.persistence.OneToOne import jakarta.persistence.PrePersist @@ -34,6 +36,7 @@ import java.util.* Index(columnList = "projectId"), Index(columnList = "authorId"), Index(columnList = "type"), + Index(columnList = "timestamp"), ], ) @EntityListeners(ActivityRevision.Companion.ActivityRevisionListener::class) @@ -106,6 +109,20 @@ class ActivityRevision : java.io.Serializable { @Column(insertable = false, updatable = false) var cancelledBatchJobExecutionCount: Int? = null + @ManyToMany + var activityGroups: MutableList = mutableListOf() + + /** + * We need to store it to know which language was the base language when the change was made + * for group and filtering purposes + */ + var baseLanguageId: Long? = null + + fun setProject(project: ProjectIdAndBaseLanguageId) { + this.projectId = project.id + this.baseLanguageId = project.baseLanguageId + } + companion object { @Configurable class ActivityRevisionListener { diff --git a/backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/S3ContentStorageConfig.kt b/backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/S3ContentStorageConfig.kt index 8611958ead..3ff25ffbd6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/S3ContentStorageConfig.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/S3ContentStorageConfig.kt @@ -9,7 +9,7 @@ import jakarta.persistence.MapsId import jakarta.persistence.OneToOne import jakarta.validation.constraints.NotBlank -@Entity() +@Entity class S3ContentStorageConfig( @MapsId @JoinColumn(name = "content_storage_id") diff --git a/backend/data/src/main/kotlin/io/tolgee/model/key/Key.kt b/backend/data/src/main/kotlin/io/tolgee/model/key/Key.kt index fd2236b77d..8993764f97 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/key/Key.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/key/Key.kt @@ -1,5 +1,6 @@ package io.tolgee.model.key +import com.fasterxml.jackson.annotation.JsonIgnore import io.tolgee.activity.annotation.ActivityDescribingProp import io.tolgee.activity.annotation.ActivityEntityDescribingPaths import io.tolgee.activity.annotation.ActivityLoggedEntity @@ -63,10 +64,12 @@ class Key( var keyScreenshotReferences: MutableList = mutableListOf() @ActivityLoggedProp + @ActivityDescribingProp @ColumnDefault("false") var isPlural: Boolean = false @ActivityLoggedProp + @ActivityDescribingProp var pluralArgName: String? = null constructor( @@ -78,6 +81,7 @@ class Key( this.translations = translations } + @get:JsonIgnore val path: PathDTO get() = PathDTO.fromFullPath(name) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationPreferences.kt b/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationPreferences.kt new file mode 100644 index 0000000000..4770be5717 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationPreferences.kt @@ -0,0 +1,68 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.model.notifications + +import io.hypersistence.utils.hibernate.type.array.EnumArrayType +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.notifications.NotificationType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import org.hibernate.annotations.Parameter +import org.hibernate.annotations.Type + +@Entity +@Table( + indexes = [ + Index( + name = "notification_preferences_user_project", + columnList = "user_account_id, project_id", + unique = true, + ), + Index( + name = "notification_preferences_user", + columnList = "user_account_id", + ), + Index( + name = "notification_preferences_project", + columnList = "project_id", + ), + ], +) +class NotificationPreferences( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + val userAccount: UserAccount, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = true) + var project: Project?, + @Type(EnumArrayType::class, parameters = [Parameter(name = EnumArrayType.SQL_ARRAY_TYPE, value = "varchar")]) + @Column(nullable = false, columnDefinition = "varchar[]") + var disabledNotifications: Array, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/notifications/UserNotification.kt b/backend/data/src/main/kotlin/io/tolgee/model/notifications/UserNotification.kt new file mode 100644 index 0000000000..7e531c5c7a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/notifications/UserNotification.kt @@ -0,0 +1,81 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.model.notifications + +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.activity.ActivityModifiedEntity +import io.tolgee.model.batch.BatchJob +import io.tolgee.notifications.NotificationType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.JoinTable +import jakarta.persistence.ManyToMany +import jakarta.persistence.ManyToOne +import jakarta.persistence.OrderBy +import jakarta.persistence.SequenceGenerator +import jakarta.persistence.Temporal +import jakarta.persistence.TemporalType +import org.hibernate.annotations.ColumnDefault +import org.hibernate.annotations.UpdateTimestamp +import java.util.* + +@Entity +class UserNotification( + @Column(nullable = false) + @Enumerated(EnumType.STRING) + val type: NotificationType, + // This data is very likely to be useless when consuming the entity: lazy + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + val recipient: UserAccount, + // We most definitely need this to show the notification: eager + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(nullable = true) + val project: Project?, + // We most definitely need this to show the notification: eager + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable(name = "user_notification_modified_entities") + val modifiedEntities: MutableList = mutableListOf(), + // We most definitely need this to show the notification: eager + @ManyToOne(fetch = FetchType.EAGER) + val batchJob: BatchJob? = null, +) { + @Id + @SequenceGenerator(name = "notification_seq", sequenceName = "sequence_notifications") + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "notification_seq") + val id: Long = 0 + + @Column(nullable = false) + @ColumnDefault("true") + var unread: Boolean = true + + @Temporal(TemporalType.TIMESTAMP) + var markedDoneAt: Date? = null + + @OrderBy + @UpdateTimestamp + @Temporal(TemporalType.TIMESTAMP) + val lastUpdated: Date = Date() +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/UserAccountProjectPermissionsNotificationPreferencesDataView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/UserAccountProjectPermissionsNotificationPreferencesDataView.kt new file mode 100644 index 0000000000..7d1252e8c1 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/UserAccountProjectPermissionsNotificationPreferencesDataView.kt @@ -0,0 +1,78 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.model.views + +import io.tolgee.model.OrganizationRole +import io.tolgee.model.Permission +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.model.enums.ProjectPermissionType +import io.tolgee.model.enums.Scope +import io.tolgee.model.notifications.NotificationPreferences +import jakarta.persistence.Id +import jakarta.persistence.OneToMany + +data class UserProjectMetadata( + @Id + val id: Long, + @Id + val projectId: Long, + @OneToMany + val organizationRole: OrganizationRole?, + @OneToMany + val basePermissions: Permission?, + @OneToMany + val permissions: Permission?, + @OneToMany + val globalNotificationPreferences: NotificationPreferences?, + @OneToMany + val projectNotificationPreferences: NotificationPreferences?, +) { + val notificationPreferences + get() = projectNotificationPreferences ?: globalNotificationPreferences +} + +class UserAccountProjectPermissionsNotificationPreferencesDataView(data: Map) { + init { + println(data["permittedViewLanguages"]) + } + + val id = data["id"] as? Long ?: throw IllegalArgumentException() + + val projectId = data["projectId"] as? Long ?: throw IllegalArgumentException() + + val organizationRole = data["organizationRole"] as? OrganizationRoleType + + val basePermissionsBasic = data["basePermissionsBasic"] as? ProjectPermissionType + + val basePermissionsGranular = + (data["basePermissionsGranular"] as? Array<*>) + ?.map { enumValueOf((it as? Enum<*>)?.name ?: throw IllegalArgumentException()) } + + val permissionsBasic = data["permissionsBasic"] as? ProjectPermissionType + + val permissionsGranular = + (data["permissionsGranular"] as? Array<*>) + ?.map { enumValueOf((it as? Enum<*>)?.name ?: throw IllegalArgumentException()) } + + val permittedViewLanguages = + (data["permittedViewLanguages"] as? Array<*>) + ?.map { (it as? String)?.toLong() ?: throw IllegalArgumentException() } + + val notificationPreferences = + (data["projectNotificationPreferences"] ?: data["globalNotificationPreferences"]) + as? NotificationPreferences +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/UserProjectMetadataView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/UserProjectMetadataView.kt new file mode 100644 index 0000000000..51b99f231c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/UserProjectMetadataView.kt @@ -0,0 +1,56 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.model.views + +import io.tolgee.model.Permission +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.model.notifications.NotificationPreferences + +class UserProjectMetadataView( + val userAccountId: Long, + val projectId: Long, + val organizationRole: OrganizationRoleType?, + basePermissions: Permission?, + fetchedBaseViewLanguages: Any?, + permissions: Permission?, + fetchedViewLanguages: Any?, + globalNotificationPreferences: NotificationPreferences?, + projectNotificationPreferences: NotificationPreferences?, +) { + val notificationPreferences = projectNotificationPreferences ?: globalNotificationPreferences + + val basePermissions = + basePermissions?.let { + println(basePermissions) + Permission.PermissionWithLanguageIdsWrapper( + basePermissions, + fetchedBaseViewLanguages, + null, + null, + ) + }?.permission + + val permissions = + permissions?.let { + Permission.PermissionWithLanguageIdsWrapper( + permissions, + fetchedViewLanguages, + null, + null, + ) + }?.permission +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/activity/SimpleModifiedEntityView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/activity/SimpleModifiedEntityView.kt new file mode 100644 index 0000000000..b04287a8df --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/activity/SimpleModifiedEntityView.kt @@ -0,0 +1,29 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.model.views.activity + +import io.tolgee.activity.data.ExistenceEntityDescription +import io.tolgee.activity.data.PropertyModification + +data class SimpleModifiedEntityView( + val entityClass: String, + val entityId: Long, + val exists: Boolean?, + var modifications: Map = mutableMapOf(), + var description: Map? = null, + var describingRelations: Map? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/notifications/NotificationPreferencesService.kt b/backend/data/src/main/kotlin/io/tolgee/notifications/NotificationPreferencesService.kt new file mode 100644 index 0000000000..031cd92ebf --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/notifications/NotificationPreferencesService.kt @@ -0,0 +1,138 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications + +import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.notifications.NotificationPreferences +import io.tolgee.notifications.dto.NotificationPreferencesDto +import io.tolgee.repository.notifications.NotificationPreferencesRepository +import io.tolgee.service.security.SecurityService +import jakarta.persistence.EntityManager +import org.springframework.stereotype.Service + +@Service +class NotificationPreferencesService( + private val entityManager: EntityManager, + private val notificationPreferencesRepository: NotificationPreferencesRepository, + private val securityService: SecurityService, +) { + fun getAllPreferences(user: Long): Map { + return notificationPreferencesRepository.findAllByUserAccountId(user) + .associate { + val key = it.project?.id?.toString() ?: "global" + val dto = NotificationPreferencesDto.fromEntity(it) + Pair(key, dto) + } + } + + fun getGlobalPreferences(user: Long): NotificationPreferencesDto { + val entity = + notificationPreferencesRepository.findByUserAccountIdAndProjectId(user, null) + ?: return NotificationPreferencesDto.createBlank() + + return NotificationPreferencesDto.fromEntity(entity) + } + + fun getProjectPreferences( + user: Long, + project: Long, + ): NotificationPreferencesDto { + // If the user cannot see the project, the project "does not exist". + val scopes = securityService.getProjectPermissionScopesNoApiKey(project, user) ?: emptyArray() + if (scopes.isEmpty()) throw NotFoundException() + + val entity = + notificationPreferencesRepository.findByUserAccountIdAndProjectId(user, null) + ?: throw NotFoundException() + + return NotificationPreferencesDto.fromEntity(entity) + } + + fun setPreferencesOfUser( + user: Long, + dto: NotificationPreferencesDto, + ): NotificationPreferences { + return setPreferencesOfUser( + entityManager.getReference(UserAccount::class.java, user), + dto, + ) + } + + fun setPreferencesOfUser( + user: UserAccount, + dto: NotificationPreferencesDto, + ): NotificationPreferences { + return doSetPreferencesOfUser(user, null, dto) + } + + fun setProjectPreferencesOfUser( + user: Long, + project: Long, + dto: NotificationPreferencesDto, + ): NotificationPreferences { + return setProjectPreferencesOfUser( + entityManager.getReference(UserAccount::class.java, user), + entityManager.getReference(Project::class.java, project), + dto, + ) + } + + fun setProjectPreferencesOfUser( + user: UserAccount, + project: Project, + dto: NotificationPreferencesDto, + ): NotificationPreferences { + // If the user cannot see the project, the project "does not exist". + val scopes = securityService.getProjectPermissionScopesNoApiKey(project.id, user.id) ?: emptyArray() + if (scopes.isEmpty()) throw NotFoundException() + + return doSetPreferencesOfUser(user, project, dto) + } + + private fun doSetPreferencesOfUser( + user: UserAccount, + project: Project?, + dto: NotificationPreferencesDto, + ): NotificationPreferences { + // Hidden as a private function as the fact global prefs and project overrides are the "same" is an implementation + // detail that should not be relied on by consumer of this service. + return notificationPreferencesRepository.save( + NotificationPreferences( + user, + project, + dto.disabledNotifications.toTypedArray(), + ), + ) + } + + fun deleteProjectPreferencesOfUser( + user: Long, + project: Long, + ) { + notificationPreferencesRepository.deleteByUserAccountIdAndProjectId(user, project) + } + + fun deleteAllByUserId(userId: Long) { + notificationPreferencesRepository.deleteAllByUserId(userId) + } + + fun deleteAllByProjectId(projectId: Long) { + notificationPreferencesRepository.deleteAllByProjectId(projectId) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/notifications/NotificationStatus.kt b/backend/data/src/main/kotlin/io/tolgee/notifications/NotificationStatus.kt new file mode 100644 index 0000000000..d80da10164 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/notifications/NotificationStatus.kt @@ -0,0 +1,23 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications + +enum class NotificationStatus { + UNREAD, + READ, + DONE, +} diff --git a/backend/data/src/main/kotlin/io/tolgee/notifications/NotificationType.kt b/backend/data/src/main/kotlin/io/tolgee/notifications/NotificationType.kt new file mode 100644 index 0000000000..cf66b8fffc --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/notifications/NotificationType.kt @@ -0,0 +1,58 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications + +import java.util.* + +enum class NotificationType { + ACTIVITY_LANGUAGES_CREATED, + ACTIVITY_KEYS_CREATED, + ACTIVITY_KEYS_UPDATED, + ACTIVITY_KEYS_SCREENSHOTS_UPLOADED, + ACTIVITY_SOURCE_STRINGS_UPDATED, + ACTIVITY_TRANSLATIONS_UPDATED, + ACTIVITY_TRANSLATION_OUTDATED, + ACTIVITY_TRANSLATION_REVIEWED, + ACTIVITY_TRANSLATION_UNREVIEWED, + ACTIVITY_NEW_COMMENTS, + ACTIVITY_COMMENTS_MENTION, + + BATCH_JOB_ERRORED, + ; + + companion object { + val ACTIVITY_NOTIFICATIONS: EnumSet = + EnumSet.of( + ACTIVITY_LANGUAGES_CREATED, + ACTIVITY_KEYS_CREATED, + ACTIVITY_KEYS_UPDATED, + ACTIVITY_KEYS_SCREENSHOTS_UPLOADED, + ACTIVITY_SOURCE_STRINGS_UPDATED, + ACTIVITY_TRANSLATIONS_UPDATED, + ACTIVITY_TRANSLATION_OUTDATED, + ACTIVITY_TRANSLATION_REVIEWED, + ACTIVITY_TRANSLATION_UNREVIEWED, + ACTIVITY_NEW_COMMENTS, + ACTIVITY_COMMENTS_MENTION, + ) + + val BATCH_JOB_NOTIFICATIONS: EnumSet = + EnumSet.of( + BATCH_JOB_ERRORED, + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/notifications/UserNotificationDebouncer.kt b/backend/data/src/main/kotlin/io/tolgee/notifications/UserNotificationDebouncer.kt new file mode 100644 index 0000000000..b3e7fa399c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/notifications/UserNotificationDebouncer.kt @@ -0,0 +1,158 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications + +import io.tolgee.model.UserAccount +import io.tolgee.model.activity.ActivityModifiedEntity +import io.tolgee.model.notifications.UserNotification +import io.tolgee.model.translation.Translation +import io.tolgee.model.translation.TranslationComment +import io.tolgee.notifications.dto.NotificationCreateDto +import io.tolgee.notifications.dto.UserNotificationParamsDto +import io.tolgee.repository.notifications.UserNotificationRepository +import org.springframework.stereotype.Component +import java.util.* + +typealias UserNotificationDebounceResult = Pair, List> + +/** + * Component responsible for debouncing notifications based on existing notifications for a given user. + */ +@Component +class UserNotificationDebouncer( + private val userNotificationRepository: UserNotificationRepository, +) { + /** + * Updates existing notifications when possible according to the debouncing policy. + * + * @return A pair of the notifications which have been updated, and the remaining notifications to process. + */ + fun debounce( + notificationDto: NotificationCreateDto, + params: List, + ): UserNotificationDebounceResult { + if (NotificationType.ACTIVITY_NOTIFICATIONS.contains(notificationDto.type)) { + return debounceActivityNotification(notificationDto, params) + } + + return Pair(emptyList(), params) + } + + // -- + // Activity-related notifications + // -- + private fun debounceActivityNotification( + notificationDto: NotificationCreateDto, + params: List, + ): UserNotificationDebounceResult { + val debouncedUserNotifications = mutableListOf() + val notificationsToProcess = mutableListOf() + val notifications = fetchRelevantActivityNotifications(notificationDto, params.map { it.recipient }) + + params.forEach { + if (notifications.containsKey(it.recipient.id)) { + val notification = notifications[it.recipient.id]!! + notificationDto.mergeIntoUserNotificationEntity(notification, it) + debouncedUserNotifications.add(notification) + } else { + notificationsToProcess.add(it) + } + } + + return Pair(debouncedUserNotifications, notificationsToProcess) + } + + private fun fetchRelevantActivityNotifications( + notificationDto: NotificationCreateDto, + recipients: List, + ): Map { + val notifications = + when { + translationUpdateNotificationTypes.contains(notificationDto.type) -> + findCandidatesForTranslationUpdateNotificationDebouncing( + notificationDto.type, + notificationDto.projectId, + recipients, + notificationDto.modifiedEntities, + ) + + commentNotificationTypes.contains(notificationDto.type) -> + findCandidatesForCommentNotificationDebouncing( + notificationDto.projectId, + recipients, + notificationDto.modifiedEntities, + ) + + else -> + userNotificationRepository.findCandidatesForNotificationDebouncing( + notificationDto.type, + notificationDto.projectId, + recipients, + ) + } + + return notifications.associateBy { it.recipient.id } + } + + private fun findCandidatesForTranslationUpdateNotificationDebouncing( + type: NotificationType, + projectId: Long, + recipients: List, + entities: List?, + ): List { + val keyId = + entities?.find { it.entityClass == Translation::class.simpleName } + ?.describingRelations?.get("key")?.entityId ?: 0L + + return userNotificationRepository.findCandidatesForTranslationUpdateNotificationDebouncing( + type, + projectId, + recipients, + keyId, + ) + } + + private fun findCandidatesForCommentNotificationDebouncing( + projectId: Long, + recipients: List, + entities: List?, + ): List { + val translationId = + entities?.find { it.entityClass == TranslationComment::class.simpleName } + ?.describingRelations?.get("translation")?.entityId ?: 0L + + return userNotificationRepository.findCandidatesForCommentNotificationDebouncing( + projectId, + recipients, + translationId, + ) + } + + companion object { + val translationUpdateNotificationTypes: EnumSet = + EnumSet.of( + NotificationType.ACTIVITY_SOURCE_STRINGS_UPDATED, + NotificationType.ACTIVITY_TRANSLATIONS_UPDATED, + ) + + val commentNotificationTypes: EnumSet = + EnumSet.of( + NotificationType.ACTIVITY_NEW_COMMENTS, + NotificationType.ACTIVITY_COMMENTS_MENTION, + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/notifications/UserNotificationService.kt b/backend/data/src/main/kotlin/io/tolgee/notifications/UserNotificationService.kt new file mode 100644 index 0000000000..976439015e --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/notifications/UserNotificationService.kt @@ -0,0 +1,141 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications + +import io.tolgee.model.notifications.UserNotification +import io.tolgee.notifications.dto.NotificationCreateDto +import io.tolgee.notifications.dto.UserNotificationParamsDto +import io.tolgee.notifications.events.UserNotificationPushEvent +import io.tolgee.repository.notifications.UserNotificationRepository +import jakarta.persistence.EntityManager +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UserNotificationService( + private val entityManager: EntityManager, + private val userNotificationRepository: UserNotificationRepository, + private val applicationEventPublisher: ApplicationEventPublisher, + private val userNotificationDebouncer: UserNotificationDebouncer, +) { + @Transactional + fun dispatchNotification( + notificationDto: NotificationCreateDto, + params: UserNotificationParamsDto, + ) { + return dispatchNotifications(notificationDto, listOf(params)) + } + + @Transactional + fun dispatchNotifications( + notificationDto: NotificationCreateDto, + params: List, + ) { + val createdUserNotificationObjects = mutableSetOf() + val updatedUserNotificationObjects = mutableSetOf() + + val (processed, remaining) = userNotificationDebouncer.debounce(notificationDto, params) + updatedUserNotificationObjects.addAll( + userNotificationRepository.saveAll(processed), + ) + + remaining.forEach { + val notification = notificationDto.toUserNotificationEntity(it, entityManager) + createdUserNotificationObjects.add( + userNotificationRepository.save(notification), + ) + } + + // Dispatch event + applicationEventPublisher.publishEvent( + UserNotificationPushEvent( + createdUserNotificationObjects, + updatedUserNotificationObjects, + ), + ) + } + + fun findNotificationsOfUserFilteredPaged( + user: Long, + status: Set, + pageable: Pageable, + ): List { + return userNotificationRepository.findNotificationsOfUserFilteredPaged( + user, + status.map { it.toString() }, + pageable, + ) + } + + fun getUnreadNotificationsCount(user: Long): Int { + return userNotificationRepository.countNotificationsByRecipientIdAndUnreadTrue(user) + } + + @Transactional + fun markAsRead( + user: Long, + notifications: Collection, + ) { + return userNotificationRepository.markAsRead(user, notifications) + } + + @Transactional + fun markAllAsRead(user: Long) { + return userNotificationRepository.markAllAsRead(user) + } + + @Transactional + fun markAsUnread( + user: Long, + notifications: Collection, + ) { + return userNotificationRepository.markAsUnread(user, notifications) + } + + @Transactional + fun markAsDone( + user: Long, + notifications: Collection, + ) { + return userNotificationRepository.markAsDone(user, notifications) + } + + @Transactional + fun markAllAsDone(user: Long) { + return userNotificationRepository.markAllAsDone(user) + } + + @Transactional + fun unmarkAsDone( + user: Long, + notifications: Collection, + ) { + return userNotificationRepository.unmarkAsDone(user, notifications) + } + + @Transactional + fun deleteAllByUserId(userId: Long) { + userNotificationRepository.deleteAllByUserId(userId) + } + + @Transactional + fun deleteAllByProjectId(projectId: Long) { + userNotificationRepository.deleteAllByProjectId(projectId) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/notifications/dispatchers/UserNotificationDispatch.kt b/backend/data/src/main/kotlin/io/tolgee/notifications/dispatchers/UserNotificationDispatch.kt new file mode 100644 index 0000000000..4d8f31ba9d --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/notifications/dispatchers/UserNotificationDispatch.kt @@ -0,0 +1,158 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications.dispatchers + +import io.tolgee.dtos.ComputedPermissionDto +import io.tolgee.model.Screenshot +import io.tolgee.model.UserAccount +import io.tolgee.model.activity.ActivityModifiedEntity +import io.tolgee.model.enums.Scope +import io.tolgee.model.translation.Translation +import io.tolgee.model.views.UserProjectMetadataView +import io.tolgee.notifications.NotificationType +import io.tolgee.notifications.UserNotificationService +import io.tolgee.notifications.dto.UserNotificationParamsDto +import io.tolgee.notifications.events.NotificationCreateEvent +import io.tolgee.service.language.LanguageService +import io.tolgee.service.security.PermissionService +import io.tolgee.service.security.UserAccountService +import io.tolgee.util.Logging +import io.tolgee.util.logger +import jakarta.persistence.EntityManager +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Async +import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.stereotype.Component + +@Component +@EnableAsync(proxyTargetClass = true) +class UserNotificationDispatch( + private val userAccountService: UserAccountService, + private val permissionService: PermissionService, + private val languageService: LanguageService, + private val userNotificationService: UserNotificationService, + private val entityManager: EntityManager, +) : Logging { + @Async + @EventListener + fun onNotificationCreate(e: NotificationCreateEvent) { + logger.trace("Received notification creation event {}", e) + + when { + NotificationType.ACTIVITY_NOTIFICATIONS.contains(e.notification.type) -> + handleActivityNotification(e) + NotificationType.BATCH_JOB_NOTIFICATIONS.contains(e.notification.type) -> + handleBatchJobNotification(e) + else -> + throw IllegalStateException("Encountered invalid notification type ${e.notification.type}") + } + } + + private fun handleActivityNotification(e: NotificationCreateEvent) { + val users = + userAccountService.getAllConnectedUserProjectMetadataViews(e.notification.projectId) + .filter { + it.notificationPreferences?.disabledNotifications?.contains(e.notification.type) != true && + it.userAccountId != e.responsibleUserId + } + + val translationToLanguageMap = + e.notification.modifiedEntities!! + .filter { it.entityClass == Translation::class.simpleName } + .map { it.entityId } + .let { if (it.isEmpty()) emptyMap() else languageService.findLanguageIdsOfTranslations(it) } + + val notifications = + users.mapNotNull { + handleActivityNotificationForUser(e, translationToLanguageMap, it) + } + + userNotificationService.dispatchNotifications(e.notification, notifications) + } + + private fun handleBatchJobNotification(e: NotificationCreateEvent) { + // Only send a full notification for job failures. + if (e.notification.type != NotificationType.BATCH_JOB_ERRORED) return + + val batchJob = e.notification.batchJob!! + val author = batchJob.author ?: return + userNotificationService.dispatchNotification( + e.notification, + UserNotificationParamsDto(author), + ) + } + + private fun handleActivityNotificationForUser( + e: NotificationCreateEvent, + translationToLanguageMap: Map, + userProjectMetadataView: UserProjectMetadataView, + ): UserNotificationParamsDto? { + val permissions = + permissionService.computeProjectPermission( + userProjectMetadataView.organizationRole, + userProjectMetadataView.basePermissions, + userProjectMetadataView.permissions, + ) + + // Filter the entities the user is allowed to see + val filteredModifiedEntities = + filterModifiedEntities( + e.notification.modifiedEntities!!, + permissions, + translationToLanguageMap, + ) + + if (filteredModifiedEntities.isEmpty()) return null + + return UserNotificationParamsDto( + recipient = entityManager.getReference(UserAccount::class.java, userProjectMetadataView.userAccountId), + modifiedEntities = filteredModifiedEntities.toSet(), + ) + } + + private fun filterModifiedEntities( + modifiedEntities: List, + permissions: ComputedPermissionDto, + translationToLanguageMap: Map, + ): Set { + return modifiedEntities + .filter { + when (it.entityClass) { + Screenshot::class.simpleName -> + isUserAllowedToSeeScreenshot(permissions) + Translation::class.simpleName -> + isUserAllowedToSeeTranslation(it, permissions, translationToLanguageMap) + else -> true + } + }.toSet() + } + + private fun isUserAllowedToSeeScreenshot(permissions: ComputedPermissionDto): Boolean { + return permissions.expandedScopes.contains(Scope.SCREENSHOTS_VIEW) + } + + private fun isUserAllowedToSeeTranslation( + entity: ActivityModifiedEntity, + permissions: ComputedPermissionDto, + translationToLanguageMap: Map, + ): Boolean { + if (!permissions.expandedScopes.contains(Scope.TRANSLATIONS_VIEW)) return false + + val language = translationToLanguageMap[entity.entityId] ?: return false + return permissions.viewLanguageIds.isNullOrEmpty() || permissions.viewLanguageIds.contains(language) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/notifications/dto/NotificationCreateDto.kt b/backend/data/src/main/kotlin/io/tolgee/notifications/dto/NotificationCreateDto.kt new file mode 100644 index 0000000000..96e4745a59 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/notifications/dto/NotificationCreateDto.kt @@ -0,0 +1,63 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications.dto + +import io.tolgee.model.Project +import io.tolgee.model.activity.ActivityModifiedEntity +import io.tolgee.model.batch.BatchJob +import io.tolgee.model.notifications.UserNotification +import io.tolgee.notifications.NotificationType +import jakarta.persistence.EntityManager + +data class NotificationCreateDto( + val type: NotificationType, + val projectId: Long, + val modifiedEntities: MutableList? = null, + val batchJob: BatchJob? = null, +) { + fun toUserNotificationEntity( + params: UserNotificationParamsDto, + em: EntityManager, + ): UserNotification { + return UserNotification( + type = type, + recipient = params.recipient, + project = em.getReference(Project::class.java, projectId), + modifiedEntities = params.modifiedEntities.toMutableList(), + ) + } + + fun mergeIntoUserNotificationEntity( + userNotification: UserNotification, + params: UserNotificationParamsDto, + ) { + when { + NotificationType.ACTIVITY_NOTIFICATIONS.contains(userNotification.type) -> + mergeIntoNotificationEntityActivity(userNotification, params) + + else -> + throw IllegalArgumentException("Cannot merge notifications of type ${userNotification.type}") + } + } + + private fun mergeIntoNotificationEntityActivity( + userNotification: UserNotification, + params: UserNotificationParamsDto, + ) { + userNotification.modifiedEntities.addAll(params.modifiedEntities) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/notifications/dto/NotificationPreferencesDto.kt b/backend/data/src/main/kotlin/io/tolgee/notifications/dto/NotificationPreferencesDto.kt new file mode 100644 index 0000000000..6b1051d5c6 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/notifications/dto/NotificationPreferencesDto.kt @@ -0,0 +1,38 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications.dto + +import io.swagger.v3.oas.annotations.media.Schema +import io.tolgee.model.notifications.NotificationPreferences +import io.tolgee.notifications.NotificationType + +data class NotificationPreferencesDto( + @Schema(description = "List of notification types the user does not want to receive.") + val disabledNotifications: List, +) { + companion object { + fun fromEntity(notificationPreferences: NotificationPreferences): NotificationPreferencesDto { + return NotificationPreferencesDto( + notificationPreferences.disabledNotifications.toList(), + ) + } + + fun createBlank(): NotificationPreferencesDto { + return NotificationPreferencesDto(emptyList()) + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/notifications/dto/UserNotificationParamsDto.kt b/backend/data/src/main/kotlin/io/tolgee/notifications/dto/UserNotificationParamsDto.kt new file mode 100644 index 0000000000..5637526046 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/notifications/dto/UserNotificationParamsDto.kt @@ -0,0 +1,25 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications.dto + +import io.tolgee.model.UserAccount +import io.tolgee.model.activity.ActivityModifiedEntity + +data class UserNotificationParamsDto( + val recipient: UserAccount, + val modifiedEntities: Set = emptySet(), +) diff --git a/backend/data/src/main/kotlin/io/tolgee/notifications/events/NotificationCreateEvent.kt b/backend/data/src/main/kotlin/io/tolgee/notifications/events/NotificationCreateEvent.kt new file mode 100644 index 0000000000..ecb6874973 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/notifications/events/NotificationCreateEvent.kt @@ -0,0 +1,25 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications.events + +import io.tolgee.notifications.dto.NotificationCreateDto + +data class NotificationCreateEvent( + val notification: NotificationCreateDto, + val responsibleUserId: Long?, + val source: Any? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/notifications/events/UserNotificationPushEvent.kt b/backend/data/src/main/kotlin/io/tolgee/notifications/events/UserNotificationPushEvent.kt new file mode 100644 index 0000000000..980aeec1b5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/notifications/events/UserNotificationPushEvent.kt @@ -0,0 +1,27 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications.events + +import io.tolgee.model.notifications.UserNotification + +/** + * Event sent when a set of users received a new notification. + */ +data class UserNotificationPushEvent( + val createdNotifications: Set, + val updatedNotifications: Set, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/notifications/listeners/ActivityEventListener.kt b/backend/data/src/main/kotlin/io/tolgee/notifications/listeners/ActivityEventListener.kt new file mode 100644 index 0000000000..64b4905a42 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/notifications/listeners/ActivityEventListener.kt @@ -0,0 +1,357 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications.listeners + +import io.tolgee.activity.data.ActivityType +import io.tolgee.activity.data.RevisionType +import io.tolgee.events.OnProjectActivityStoredEvent +import io.tolgee.model.Screenshot +import io.tolgee.model.activity.ActivityModifiedEntity +import io.tolgee.model.enums.TranslationState +import io.tolgee.model.key.Key +import io.tolgee.model.key.KeyMeta +import io.tolgee.model.translation.Translation +import io.tolgee.notifications.NotificationType +import io.tolgee.notifications.dto.NotificationCreateDto +import io.tolgee.notifications.events.NotificationCreateEvent +import io.tolgee.service.language.LanguageService +import io.tolgee.util.Logging +import io.tolgee.util.logger +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +private typealias SortedTranslations = List>> + +@Component +class ActivityEventListener( + private val applicationEventPublisher: ApplicationEventPublisher, + private val languageService: LanguageService, +) : Logging { + @EventListener + @Transactional + fun onActivityRevision(e: OnProjectActivityStoredEvent) { + // Using the Stored variant so `modifiedEntities` is populated. + + logger.trace( + "Received project activity event - {} on proj#{} ({} entities modified)", + e.activityRevision.type, + e.activityRevision.projectId, + e.activityRevision.modifiedEntities.size, + ) + + val projectId = e.activityRevision.projectId ?: return + val responsibleUserId = e.activityRevision.authorId + + when (e.activityRevision.type) { + // ACTIVITY_LANGUAGES_CREATED + ActivityType.CREATE_LANGUAGE -> + processSimpleActivity(NotificationType.ACTIVITY_LANGUAGES_CREATED, projectId, responsibleUserId, e) + + // ACTIVITY_KEYS_CREATED + ActivityType.CREATE_KEY -> + processSimpleActivity(NotificationType.ACTIVITY_KEYS_CREATED, projectId, responsibleUserId, e) + + // ACTIVITY_KEYS_UPDATED + ActivityType.KEY_TAGS_EDIT, + ActivityType.KEY_NAME_EDIT, + ActivityType.BATCH_TAG_KEYS, + ActivityType.COMPLEX_TAG_OPERATION, + ActivityType.BATCH_UNTAG_KEYS, + ActivityType.BATCH_SET_KEYS_NAMESPACE, + -> + processSimpleActivity(NotificationType.ACTIVITY_KEYS_UPDATED, projectId, responsibleUserId, e) + + // ACTIVITY_KEYS_UPDATED, ACTIVITY_KEYS_SCREENSHOTS_UPLOADED, ACTIVITY_TRANSLATIONS_* + ActivityType.COMPLEX_EDIT -> + processComplexEdit(projectId, responsibleUserId, e) + + // ACTIVITY_KEYS_SCREENSHOTS_UPLOADED + ActivityType.SCREENSHOT_ADD -> + processScreenshotUpdate(projectId, responsibleUserId, e) + + // ACTIVITY_TRANSLATIONS_* + ActivityType.SET_TRANSLATIONS, + ActivityType.SET_TRANSLATION_STATE, + ActivityType.BATCH_PRE_TRANSLATE_BY_TM, + ActivityType.BATCH_MACHINE_TRANSLATE, + ActivityType.AUTO_TRANSLATE, + ActivityType.BATCH_CLEAR_TRANSLATIONS, + ActivityType.BATCH_COPY_TRANSLATIONS, + ActivityType.BATCH_SET_TRANSLATION_STATE, + -> + processSetTranslations(projectId, responsibleUserId, e) + + ActivityType.SET_OUTDATED_FLAG -> + processOutdatedFlagUpdate(projectId, responsibleUserId, e) + + // ACTIVITY_NEW_COMMENTS (ACTIVITY_COMMENTS_MENTION is user-specific and not computed here) + ActivityType.TRANSLATION_COMMENT_ADD -> + processSimpleActivity(NotificationType.ACTIVITY_NEW_COMMENTS, projectId, responsibleUserId, e) + + // ACTIVITY_KEYS_CREATED, ACTIVITY_TRANSLATIONS_* + ActivityType.IMPORT -> + processImport(projectId, responsibleUserId, e) + + // We don't care about those, ignore them. + // They're explicitly not written as a single `else` branch, + // so it causes a compile error when new activities are added + // and ensures the notification policy is adjusted accordingly. + ActivityType.UNKNOWN, + ActivityType.DISMISS_AUTO_TRANSLATED_STATE, + ActivityType.TRANSLATION_COMMENT_DELETE, + ActivityType.TRANSLATION_COMMENT_EDIT, + ActivityType.TRANSLATION_COMMENT_SET_STATE, + ActivityType.SCREENSHOT_DELETE, + ActivityType.KEY_DELETE, + ActivityType.EDIT_LANGUAGE, + ActivityType.DELETE_LANGUAGE, + ActivityType.CREATE_PROJECT, + ActivityType.EDIT_PROJECT, + ActivityType.NAMESPACE_EDIT, + ActivityType.AUTOMATION, + ActivityType.CONTENT_DELIVERY_CONFIG_CREATE, + ActivityType.CONTENT_DELIVERY_CONFIG_UPDATE, + ActivityType.CONTENT_DELIVERY_CONFIG_DELETE, + ActivityType.CONTENT_STORAGE_CREATE, + ActivityType.CONTENT_STORAGE_UPDATE, + ActivityType.CONTENT_STORAGE_DELETE, + ActivityType.WEBHOOK_CONFIG_CREATE, + ActivityType.WEBHOOK_CONFIG_UPDATE, + ActivityType.WEBHOOK_CONFIG_DELETE, + ActivityType.HARD_DELETE_LANGUAGE, + null, + -> {} + } + } + + /** + * Handles activities that can be simply mapped to a corresponding notification without extra processing. + */ + private fun processSimpleActivity( + type: NotificationType, + projectId: Long, + responsibleUserId: Long?, + e: OnProjectActivityStoredEvent, + ) { + applicationEventPublisher.publishEvent( + NotificationCreateEvent( + NotificationCreateDto(type, projectId, e.activityRevision.modifiedEntities), + responsibleUserId, + source = e, + ), + ) + } + + /** + * Emits multiple notification create events depending on the details of the complex edition. + */ + private fun processComplexEdit( + projectId: Long, + responsibleUserId: Long?, + e: OnProjectActivityStoredEvent, + ) { + processComplexEditKeyUpdate(projectId, responsibleUserId, e) + processScreenshotUpdate(projectId, responsibleUserId, e) + processSetTranslations(projectId, responsibleUserId, e) + } + + private fun processComplexEditKeyUpdate( + projectId: Long, + responsibleUserId: Long?, + e: OnProjectActivityStoredEvent, + ) { + // The key was updated if: + // The entity is a Key and its name or namespace was modified; + // The entity is a KeyMeta and its tags were modified. + val relevantEntities = + e.activityRevision.modifiedEntities.filter { + ( + it.entityClass == Key::class.simpleName && + (it.modifications.containsKey("name") || it.modifications.containsKey("namespace")) + ) || + ( + it.entityClass == KeyMeta::class.simpleName && + it.modifications.containsKey("tags") + ) + } + + if (relevantEntities.isNotEmpty()) { + applicationEventPublisher.publishEvent( + NotificationCreateEvent( + NotificationCreateDto(NotificationType.ACTIVITY_KEYS_UPDATED, projectId, relevantEntities.toMutableList()), + responsibleUserId, + source = e, + ), + ) + } + } + + /** + * Emits notifications based on whether a translation was added, updated or removed. + * + * Refer to the Hacking Documentation, Notifications, Activity Notifications - User Dispatch, §2.2.2. + */ + private fun processSetTranslations( + projectId: Long, + responsibleUserId: Long?, + e: OnProjectActivityStoredEvent, + modifiedEntities: List = e.activityRevision.modifiedEntities, + ) { + val baseLanguageId = languageService.getBaseLanguageForProjectId(projectId) + val sortedTranslations = + sortTranslations( + modifiedEntities, + baseLanguage = baseLanguageId ?: 0L, + ) + + for ((type, translations) in sortedTranslations) { + if (translations.isNotEmpty()) { + applicationEventPublisher.publishEvent( + NotificationCreateEvent( + NotificationCreateDto(type, projectId, translations), + responsibleUserId, + source = e, + ), + ) + } + } + } + + private fun sortTranslations( + entities: List, + baseLanguage: Long, + ): SortedTranslations { + val updatedSourceTranslations = mutableListOf() + val updatedTranslations = mutableListOf() + val reviewedTranslations = mutableListOf() + val unreviewedTranslations = mutableListOf() + + entities.forEach { + if (it.entityClass != Translation::class.simpleName) return@forEach + + val text = it.modifications["text"] + val state = it.modifications["state"] + + if (text != null) { + val languageId = it.describingRelations?.get("language")?.entityId + if (languageId == baseLanguage) { + updatedSourceTranslations.add(it) + } else { + updatedTranslations.add(it) + } + } + + if (state?.new == TranslationState.REVIEWED.name) { + reviewedTranslations.add(it) + } + if (state?.new == TranslationState.TRANSLATED.name && state.old == TranslationState.REVIEWED.name) { + unreviewedTranslations.add(it) + } + } + + return listOf( + Pair(NotificationType.ACTIVITY_SOURCE_STRINGS_UPDATED, updatedSourceTranslations), + Pair(NotificationType.ACTIVITY_TRANSLATIONS_UPDATED, updatedTranslations), + Pair(NotificationType.ACTIVITY_TRANSLATION_REVIEWED, reviewedTranslations), + Pair(NotificationType.ACTIVITY_TRANSLATION_UNREVIEWED, unreviewedTranslations), + ) + } + + private fun processOutdatedFlagUpdate( + projectId: Long, + responsibleUserId: Long?, + e: OnProjectActivityStoredEvent, + ) { + val outdatedTranslations = + e.activityRevision.modifiedEntities + .filter { it.modifications["outdated"]?.new == true } + + if (outdatedTranslations.isNotEmpty()) { + applicationEventPublisher.publishEvent( + NotificationCreateEvent( + NotificationCreateDto( + type = NotificationType.ACTIVITY_TRANSLATION_OUTDATED, + projectId = projectId, + modifiedEntities = outdatedTranslations.toMutableList(), + ), + responsibleUserId, + source = e, + ), + ) + } + } + + private fun processScreenshotUpdate( + projectId: Long, + responsibleUserId: Long?, + e: OnProjectActivityStoredEvent, + ) { + val addedScreenshots = + e.activityRevision.modifiedEntities + .filter { it.entityClass == Screenshot::class.simpleName && it.revisionType == RevisionType.ADD } + + if (addedScreenshots.isNotEmpty()) { + applicationEventPublisher.publishEvent( + NotificationCreateEvent( + NotificationCreateDto( + type = NotificationType.ACTIVITY_KEYS_SCREENSHOTS_UPLOADED, + projectId = projectId, + modifiedEntities = addedScreenshots.toMutableList(), + ), + responsibleUserId, + source = e, + ), + ) + } + } + + private fun processImport( + projectId: Long, + responsibleUserId: Long?, + e: OnProjectActivityStoredEvent, + ) { + val createdKeys = + e.activityRevision.modifiedEntities + .filter { it.entityClass == Key::class.simpleName && it.revisionType == RevisionType.ADD } + .toMutableList() + + val keyIds = createdKeys.map { it.entityId }.toSet() + val updatedTranslations = + e.activityRevision.modifiedEntities + .filter { + val isFromNewKey = keyIds.contains(it.describingRelations?.get("key")?.entityId ?: 0L) + if (isFromNewKey) createdKeys.add(it) + !isFromNewKey + } + + if (createdKeys.isNotEmpty()) { + applicationEventPublisher.publishEvent( + NotificationCreateEvent( + NotificationCreateDto(NotificationType.ACTIVITY_KEYS_CREATED, projectId, createdKeys.toMutableList()), + responsibleUserId, + source = e, + ), + ) + } + + if (updatedTranslations.isNotEmpty()) { + processSetTranslations(projectId, responsibleUserId, e, updatedTranslations) + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/notifications/listeners/BatchJobListener.kt b/backend/data/src/main/kotlin/io/tolgee/notifications/listeners/BatchJobListener.kt new file mode 100644 index 0000000000..be85de420f --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/notifications/listeners/BatchJobListener.kt @@ -0,0 +1,57 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.notifications.listeners + +import io.tolgee.batch.events.OnBatchJobFailed +import io.tolgee.model.batch.BatchJob +import io.tolgee.notifications.NotificationType +import io.tolgee.notifications.dto.NotificationCreateDto +import io.tolgee.notifications.events.NotificationCreateEvent +import io.tolgee.util.Logging +import io.tolgee.util.logger +import jakarta.persistence.EntityManager +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +@Component +class BatchJobListener( + private val applicationEventPublisher: ApplicationEventPublisher, + private val entityManager: EntityManager, +) : Logging { + @EventListener + fun onBatchJobError(e: OnBatchJobFailed) { + logger.trace( + "Received batch job failure event - job#{} on proj#{}", + e.job.id, + e.job.projectId, + ) + + val job = entityManager.getReference(BatchJob::class.java, e.job.id) + applicationEventPublisher.publishEvent( + NotificationCreateEvent( + NotificationCreateDto( + type = NotificationType.BATCH_JOB_ERRORED, + projectId = e.job.projectId, + batchJob = job, + ), + source = e, + responsibleUserId = null, + ), + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/LanguageRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/LanguageRepository.kt index 6e090cf3cb..323a70d7b6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/LanguageRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/LanguageRepository.kt @@ -2,6 +2,7 @@ package io.tolgee.repository import io.tolgee.dtos.cacheable.LanguageDto import io.tolgee.model.Language +import io.tolgee.model.Project import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository @@ -20,7 +21,7 @@ interface LanguageRepository : JpaRepository { ) fun findByNameAndProject( name: String?, - project: io.tolgee.model.Project, + project: Project, ): Optional @Query( @@ -107,4 +108,16 @@ interface LanguageRepository : JpaRepository { """, ) fun findAllDtosByProjectId(projectId: Long): List + + @Query( + """ + SELECT new map(t.id as translationId, t.language.id as languageId) + FROM Translation t + WHERE t.id IN :translationIds + """, + ) + fun findLanguageIdsOfTranslations(translationIds: List): List> + + @Query("SELECT l.id FROM Language l, Project p WHERE p.id = :projectId AND l = p.baseLanguage") + fun getBaseLanguageForProjectId(projectId: Long): Long? } diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/PermissionRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/PermissionRepository.kt index 828de80567..a6cf7d6358 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/PermissionRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/PermissionRepository.kt @@ -10,18 +10,28 @@ import org.springframework.stereotype.Repository interface PermissionRepository : JpaRepository { @Query( """ - from Permission p - where + SELECT DISTINCT new io.tolgee.model.Permission${'$'}PermissionWithLanguageIdsWrapper( + p, + listagg(str(vl.id), ','), + listagg(str(tl.id), ','), + listagg(str(sl.id), ',') + ) + FROM Permission p + LEFT JOIN p.viewLanguages vl + LEFT JOIN p.translateLanguages tl + LEFT JOIN p.stateChangeLanguages sl + WHERE ((:projectId is null and p.project.id is null) or p.project.id = :projectId) and ((:userId is null and p.user.id is null) or p.user.id = :userId) and ((:organizationId is null and p.organization.id is null) or p.organization.id = :organizationId) + GROUP BY p """, ) fun findOneByProjectIdAndUserIdAndOrganizationId( projectId: Long?, userId: Long?, organizationId: Long? = null, - ): Permission? + ): Permission.PermissionWithLanguageIdsWrapper? fun getAllByProjectAndUserNotNull(project: io.tolgee.model.Project?): Set diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt index 413f0b02fa..f042407901 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt @@ -4,6 +4,7 @@ import io.tolgee.dtos.queryResults.UserAccountView import io.tolgee.model.UserAccount import io.tolgee.model.views.UserAccountInProjectView import io.tolgee.model.views.UserAccountWithOrganizationRoleView +import io.tolgee.model.views.UserProjectMetadataView import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository @@ -162,7 +163,7 @@ interface UserAccountRepository : JpaRepository { @Query( value = """ - select ua from UserAccount ua where id in (:ids) + select ua from UserAccount ua where ua.id in (:ids) """, ) fun getAllByIdsIncludingDeleted(ids: Set): MutableList @@ -209,4 +210,43 @@ interface UserAccountRepository : JpaRepository { """, ) fun findDemoByUsernames(usernames: List): List + + @Query( + """ + SELECT DISTINCT new io.tolgee.model.views.UserProjectMetadataView( + ua.id, + p.id, + org_r.type, + perm_org, + listagg(str(vl_org.id), ','), + perm, + listagg(str(vl.id), ','), + np_global, + np_project + ) + FROM UserAccount ua + LEFT JOIN Project p ON p.id = :projectId + LEFT JOIN OrganizationRole org_r ON + org_r.user = ua AND + org_r.organization = p.organizationOwner + LEFT JOIN FETCH Permission perm ON + perm.user = ua AND + perm.project = p + LEFT JOIN perm.viewLanguages vl + LEFT JOIN FETCH Permission perm_org ON + org_r.user = ua AND + org_r.organization = p.organizationOwner AND + perm_org.organization = p.organizationOwner + LEFT JOIN perm_org.viewLanguages vl_org + LEFT JOIN FETCH NotificationPreferences np_global ON np_global.userAccount = ua AND np_global.project IS NULL + LEFT JOIN FETCH NotificationPreferences np_project ON np_project.userAccount = ua AND np_project.project = p + WHERE + ua.deletedAt IS NULL AND ( + (perm._scopes IS NOT NULL AND cast(perm._scopes as string) != '{}') OR perm.type IS NOT NULL OR + (perm_org._scopes IS NOT NULL AND cast(perm_org._scopes as string) != '{}') OR perm_org.type IS NOT NULL + ) + GROUP BY ua.id, p.id, org_r.type, perm_org, perm, np_global, np_project + """, + ) + fun findAllUserProjectMetadataViews(projectId: Long): List } diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/activity/ActivityGroupRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/activity/ActivityGroupRepository.kt new file mode 100644 index 0000000000..ced07861c2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/repository/activity/ActivityGroupRepository.kt @@ -0,0 +1,38 @@ +package io.tolgee.repository.activity + +import io.tolgee.model.activity.ActivityGroup +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +interface ActivityGroupRepository : JpaRepository { + @Query( + nativeQuery = true, + value = """ + SELECT + ag.id, + ag.type, + ag.matching_string, + MAX(ar.timestamp) AS last_activity, + MIN(ar.timestamp) AS first_activity + FROM activity_group ag + left JOIN activity_revision_activity_groups arag ON arag.activity_groups_id = ag.id + left JOIN activity_revision ar ON ar.id = arag.activity_revisions_id + WHERE + ag.project_id = :projectId + AND ag.author_id = :authorId + AND ag.type = :groupTypeName + AND (ag.matching_string = :matchingString or (:matchingString is null)) + GROUP BY ag.id + order by ag.id desc + limit 1 + """, + ) + fun findLatest( + groupTypeName: String, + authorId: Long?, + projectId: Long?, + matchingString: String?, + ): List> +} diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/activity/ActivityRevisionRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/activity/ActivityRevisionRepository.kt index 5fd28dde46..00e0b30d99 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/activity/ActivityRevisionRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/activity/ActivityRevisionRepository.kt @@ -1,6 +1,7 @@ package io.tolgee.repository.activity import io.tolgee.activity.data.ActivityType +import io.tolgee.model.activity.ActivityDescribingEntity import io.tolgee.model.activity.ActivityRevision import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -22,6 +23,20 @@ interface ActivityRevisionRepository : JpaRepository { types: List, ): Page + @Query( + """ + select dr + from ActivityRevision ar + join ar.describingRelations dr + where ar.id in :revisionIds + and ar.type in :allowedTypes + """, + ) + fun getRelationsForRevisions( + revisionIds: List, + allowedTypes: Collection, + ): List + @Query( """ select ar.id, me.entityClass, count(me) diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/notifications/NotificationPreferencesRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/notifications/NotificationPreferencesRepository.kt new file mode 100644 index 0000000000..a3b5b3883b --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/repository/notifications/NotificationPreferencesRepository.kt @@ -0,0 +1,46 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.repository.notifications + +import io.tolgee.model.notifications.NotificationPreferences +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +interface NotificationPreferencesRepository : JpaRepository { + fun findAllByUserAccountId(user: Long): List + + fun findByUserAccountIdAndProjectId( + user: Long, + project: Long?, + ): NotificationPreferences? + + fun deleteByUserAccountIdAndProjectId( + user: Long, + project: Long, + ) + + @Modifying + @Query("DELETE FROM NotificationPreferences WHERE userAccount.id = :userId") + fun deleteAllByUserId(userId: Long) + + @Modifying + @Query("DELETE FROM NotificationPreferences WHERE project.id = :projectId") + fun deleteAllByProjectId(projectId: Long) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/notifications/UserNotificationRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/notifications/UserNotificationRepository.kt new file mode 100644 index 0000000000..2bab9a6f9d --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/repository/notifications/UserNotificationRepository.kt @@ -0,0 +1,171 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.repository.notifications + +import io.tolgee.model.UserAccount +import io.tolgee.model.notifications.UserNotification +import io.tolgee.notifications.NotificationType +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +interface UserNotificationRepository : JpaRepository { + fun findAllByRecipient(recipient: UserAccount): List + + fun countNotificationsByRecipientIdAndUnreadTrue(recipient: Long): Int + + @Query( + """ + FROM UserNotification un WHERE + un.recipient.id = :recipient AND ( + ('UNREAD' IN :status AND un.unread = true AND un.markedDoneAt IS NULL) OR + ('READ' IN :status AND un.unread = false AND un.markedDoneAt IS NULL) OR + ('DONE' IN :status AND un.markedDoneAt IS NOT NULL) + ) + """, + ) + fun findNotificationsOfUserFilteredPaged( + recipient: Long, + status: List, + pageable: Pageable, + ): List + + @Query( + """ + FROM UserNotification un + WHERE + un.unread = true AND + un.type = :type AND + un.project.id = :projectId AND + un.recipient IN :recipients + """, + ) + fun findCandidatesForNotificationDebouncing( + type: NotificationType, + projectId: Long, + recipients: Collection, + ): List + + @Query( + """ + SELECT un + FROM UserNotification un + INNER JOIN un.modifiedEntities me + WHERE + un.unread = true AND + un.project.id = :projectId AND + un.recipient IN :recipients AND ( + un.type = :type OR ( + un.type = io.tolgee.notifications.NotificationType.ACTIVITY_KEYS_CREATED AND + me.entityClass = 'Key' AND + me.entityId = :keyId + ) + ) + ORDER BY un.type DESC + """, + ) + fun findCandidatesForTranslationUpdateNotificationDebouncing( + type: NotificationType, + projectId: Long, + recipients: Collection, + keyId: Long, + ): List + + @Query( + """ + SELECT un + FROM UserNotification un + INNER JOIN un.modifiedEntities me + INNER JOIN ActivityDescribingEntity de ON de.activityRevision = me.activityRevision + WHERE + un.unread = true AND + me.entityClass = 'TranslationComment' AND + de.entityClass = 'Translation' AND + de.entityId = :translationId AND + un.project.id = :projectId AND + un.recipient IN :recipients AND + un.type IN ( + io.tolgee.notifications.NotificationType.ACTIVITY_NEW_COMMENTS, + io.tolgee.notifications.NotificationType.ACTIVITY_COMMENTS_MENTION + ) + """, + ) + fun findCandidatesForCommentNotificationDebouncing( + projectId: Long, + recipients: Collection, + translationId: Long, + ): List + + @Modifying + @Query("UPDATE UserNotification un SET un.unread = false WHERE un.recipient.id = ?1 AND un.id IN ?2") + fun markAsRead( + recipient: Long, + notifications: Collection, + ) + + @Modifying + @Query("UPDATE UserNotification un SET un.unread = false WHERE un.recipient.id = ?1") + fun markAllAsRead(recipient: Long) + + @Modifying + @Query("UPDATE UserNotification un SET un.unread = true WHERE un.recipient.id = ?1 AND un.id IN ?2") + fun markAsUnread( + recipient: Long, + notifications: Collection, + ) + + @Modifying + @Query( + """ + UPDATE UserNotification un + SET un.unread = false, un.markedDoneAt = CURRENT_TIMESTAMP() + WHERE un.recipient.id = ?1 AND un.id IN ?2 + """, + ) + fun markAsDone( + recipient: Long, + notifications: Collection, + ) + + @Modifying + @Query( + """ + UPDATE UserNotification un + SET un.unread = false, un.markedDoneAt = CURRENT_TIMESTAMP() + WHERE un.recipient.id = ?1 + """, + ) + fun markAllAsDone(recipient: Long) + + @Modifying + @Query("UPDATE UserNotification un SET un.markedDoneAt = null WHERE un.recipient.id = ?1 AND un.id IN ?2") + fun unmarkAsDone( + recipient: Long, + notifications: Collection, + ) + + @Modifying + @Query("DELETE FROM UserNotification WHERE recipient.id = :userId") + fun deleteAllByUserId(userId: Long) + + @Modifying + @Query("DELETE FROM UserNotification WHERE project.id = :projectId") + fun deleteAllByProjectId(projectId: Long) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/CachedPermissionService.kt b/backend/data/src/main/kotlin/io/tolgee/service/CachedPermissionService.kt index 9cf66c7ff4..e9285cd627 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/CachedPermissionService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/CachedPermissionService.kt @@ -51,7 +51,7 @@ class CachedPermissionService( projectId = projectId, userId = userId, organizationId = organizationId, - )?.let { permission -> + )?.permission?.let { permission -> PermissionDto( id = permission.id, userId = permission.user?.id, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageService.kt b/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageService.kt index 6604eb8d01..f24b8b56f0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageService.kt @@ -101,7 +101,7 @@ class LanguageService( ) { activityHolder.activity = ActivityType.HARD_DELETE_LANGUAGE activityHolder.activityRevision.authorId = authorId - activityHolder.activityRevision.projectId = language.project.id + activityHolder.activityRevision.setProject(language.project) hardDeleteLanguage(language) } @@ -296,6 +296,16 @@ class LanguageService( return languageRepository.findByNameAndProject(name, project) } + fun findLanguageIdsOfTranslations(translationIds: List): Map { + val maps = languageRepository.findLanguageIdsOfTranslations(translationIds) + if (maps.isEmpty()) return emptyMap() + + return maps + .map { mapOf(it["translationId"]!! to it["languageId"]!!) } + .reduce { acc, map -> acc.plus(map) } + } + + @Transactional fun deleteAllByProject(projectId: Long) { translationService.deleteAllByProject(projectId) autoTranslationService.deleteConfigsByProject(projectId) @@ -445,4 +455,8 @@ class LanguageService( language.project = entityManager.getReference(Project::class.java, projectId) return languageRepository.save(language) } + + fun getBaseLanguageForProjectId(projectId: Long): Long? { + return languageRepository.getBaseLanguageForProjectId(projectId) + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt index 797030d939..0985cb6dca 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt @@ -21,6 +21,8 @@ import io.tolgee.model.Project import io.tolgee.model.UserAccount import io.tolgee.model.views.ProjectView import io.tolgee.model.views.ProjectWithLanguagesView +import io.tolgee.notifications.NotificationPreferencesService +import io.tolgee.notifications.UserNotificationService import io.tolgee.repository.ProjectRepository import io.tolgee.security.ProjectHolder import io.tolgee.security.ProjectNotSelectedException @@ -62,10 +64,10 @@ class ProjectService( private val slugGenerator: SlugGenerator, private val avatarService: AvatarService, private val activityHolder: ActivityHolder, - @Lazy - private val projectHolder: ProjectHolder, - @Lazy - private val batchJobService: BatchJobService, + @Lazy private val projectHolder: ProjectHolder, + @Lazy private val batchJobService: BatchJobService, + @Lazy private val userNotificationService: UserNotificationService, + @Lazy private val notificationPreferencesService: NotificationPreferencesService, private val currentDateProvider: CurrentDateProvider, ) : Logging { @set:Autowired @@ -309,12 +311,16 @@ class ProjectService( avatarService.unlinkAvatarFiles(project) batchJobService.deleteAllByProjectId(project.id) bigMetaService.deleteAllByProjectId(project.id) + + userNotificationService.deleteAllByProjectId(project.id) + notificationPreferencesService.deleteAllByProjectId(project.id) + projectRepository.delete(project) } } /** - * If base language is missing on project it selects language with lowest id + * If base language is missing on project it selects the language with the lowest id * It saves updated project and returns project's new baseLanguage */ @CacheEvict(cacheNames = [Caches.PROJECTS], key = "#projectId") @@ -398,7 +404,7 @@ class ProjectService( projectRepository.save(project) if (isCreating) { projectHolder.project = ProjectDto.fromEntity(project) - activityHolder.activityRevision.projectId = projectHolder.project.id + activityHolder.activityRevision.setProject(project) } return project } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt index ae3063dc88..df142f59c8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt @@ -22,6 +22,7 @@ import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.model.enums.ProjectPermissionType import io.tolgee.model.enums.Scope +import io.tolgee.model.views.UserProjectMetadataView import io.tolgee.repository.PermissionRepository import io.tolgee.service.CachedPermissionService import io.tolgee.service.language.LanguageService @@ -194,7 +195,7 @@ class PermissionService( fun computeProjectPermission( organizationRole: OrganizationRoleType?, - organizationBasePermission: IPermission, + organizationBasePermission: IPermission?, directPermission: IPermission?, userRole: UserAccount.Role? = null, ): ComputedPermissionDto { @@ -202,7 +203,7 @@ class PermissionService( when { organizationRole == OrganizationRoleType.OWNER -> ComputedPermissionDto.ORGANIZATION_OWNER directPermission != null -> ComputedPermissionDto(directPermission, ComputedPermissionOrigin.DIRECT) - organizationRole == OrganizationRoleType.MEMBER -> + organizationRole == OrganizationRoleType.MEMBER && organizationBasePermission != null -> ComputedPermissionDto( organizationBasePermission, ComputedPermissionOrigin.ORGANIZATION_BASE, @@ -216,6 +217,14 @@ class PermissionService( } ?: computed } + fun computeProjectPermission(userProjectMetadataView: UserProjectMetadataView): ComputedPermissionDto { + return computeProjectPermission( + userProjectMetadataView.organizationRole, + userProjectMetadataView.basePermissions, + userProjectMetadataView.permissions, + ) + } + fun createForInvitation( invitation: Invitation, params: CreateProjectInvitationParams, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt index ca345c62d4..8ac3a7f790 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt @@ -21,6 +21,9 @@ import io.tolgee.model.UserAccount import io.tolgee.model.views.ExtendedUserAccountInProject import io.tolgee.model.views.UserAccountInProjectView import io.tolgee.model.views.UserAccountWithOrganizationRoleView +import io.tolgee.model.views.UserProjectMetadataView +import io.tolgee.notifications.NotificationPreferencesService +import io.tolgee.notifications.UserNotificationService import io.tolgee.repository.UserAccountRepository import io.tolgee.service.AvatarService import io.tolgee.service.EmailVerificationService @@ -55,6 +58,8 @@ class UserAccountService( private val organizationService: OrganizationService, private val entityManager: EntityManager, private val currentDateProvider: CurrentDateProvider, + @Lazy private val userNotificationService: UserNotificationService, + @Lazy private val notificationPreferencesService: NotificationPreferencesService, private val cacheManager: CacheManager, @Suppress("SelfReferenceConstructorParameter") @Lazy @@ -203,6 +208,10 @@ class UserAccountService( toDelete.organizationRoles.forEach { entityManager.remove(it) } + + userNotificationService.deleteAllByUserId(toDelete.id) + notificationPreferencesService.deleteAllByUserId(toDelete.id) + userAccountRepository.softDeleteUser(toDelete, currentDateProvider.date) applicationEventPublisher.publishEvent(OnUserCountChanged(decrease = true, this)) } @@ -374,6 +383,10 @@ class UserAccountService( } } + fun getAllConnectedUserProjectMetadataViews(projectId: Long): List { + return userAccountRepository.findAllUserProjectMetadataViews(projectId) + } + @Transactional @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#result.id") fun update( diff --git a/backend/data/src/main/kotlin/io/tolgee/sharedDocs/Key.kt b/backend/data/src/main/kotlin/io/tolgee/sharedDocs/Key.kt new file mode 100644 index 0000000000..1f3855ed26 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/sharedDocs/Key.kt @@ -0,0 +1,6 @@ +package io.tolgee.sharedDocs + +object Key { + const val IS_PLURAL_FIELD = "If key is pluralized. If it will be reflected in the editor" + const val PLURAL_ARG_NAME_FIELD = "The argument name for the plural" +} diff --git a/backend/data/src/main/kotlin/io/tolgee/util/dateExt.kt b/backend/data/src/main/kotlin/io/tolgee/util/dateExt.kt index f22821dccc..25526c2f40 100644 --- a/backend/data/src/main/kotlin/io/tolgee/util/dateExt.kt +++ b/backend/data/src/main/kotlin/io/tolgee/util/dateExt.kt @@ -28,6 +28,12 @@ fun Date.addSeconds(seconds: Int): Date { return calendar.time } +fun Date.addMilliseconds(milliseconds: Int): Date { + val calendar = toUtcCalendar() + calendar.add(Calendar.MILLISECOND, milliseconds) + return calendar.time +} + private fun Date.toUtcCalendar(): Calendar { val calendar = getUtcCalendar() calendar.time = this diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 95c9ad0522..8509e4c786 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -3418,4 +3418,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/data/src/main/resources/hibernate-types.properties b/backend/data/src/main/resources/hypersistence-utils.properties similarity index 100% rename from backend/data/src/main/resources/hibernate-types.properties rename to backend/data/src/main/resources/hypersistence-utils.properties diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/NotificationsE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/NotificationsE2eDataController.kt new file mode 100644 index 0000000000..0a5bcdb11f --- /dev/null +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/NotificationsE2eDataController.kt @@ -0,0 +1,55 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.controllers.internal.e2eData + +import io.swagger.v3.oas.annotations.Hidden +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.development.testDataBuilder.data.NotificationsTestData +import io.tolgee.repository.UserAccountRepository +import io.tolgee.service.security.UserAccountService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.CrossOrigin +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@CrossOrigin(origins = ["*"]) +@Hidden +@RequestMapping(value = ["internal/e2e-data/notifications"]) +class NotificationsE2eDataController : AbstractE2eDataController() { + @Autowired + lateinit var userAccountRepository: UserAccountRepository + + @Autowired + lateinit var userAccountService: UserAccountService + + @GetMapping(value = ["/generate"]) + @Transactional + fun generateBasicTestData() { + userAccountService.findActive("admin")?.let { + userAccountService.delete(it) + userAccountRepository.delete(it) + } + + testDataService.saveTestData(testData) + } + + override val testData: TestDataBuilder + get() = NotificationsTestData().root +} diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptor.kt index 7757f21723..3857d13b76 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptor.kt @@ -139,7 +139,7 @@ class ProjectAuthorizationInterceptor( } projectHolder.project = project - activityHolder.activityRevision.projectId = project.id + activityHolder.activityRevision.setProject(project) organizationHolder.organization = organizationService.findDto(project.organizationOwnerId) ?: throw NotFoundException(Message.ORGANIZATION_NOT_FOUND) diff --git a/backend/testing/build.gradle b/backend/testing/build.gradle index fae4d407ea..8902fdef40 100644 --- a/backend/testing/build.gradle +++ b/backend/testing/build.gradle @@ -76,8 +76,7 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-validation" implementation("org.springframework.boot:spring-boot-starter-security") implementation 'org.springframework.boot:spring-boot-starter-mail' - implementation('org.springframework.boot:spring-boot-starter-test') { - } + implementation('org.springframework.boot:spring-boot-starter-test') kapt "org.springframework.boot:spring-boot-configuration-processor" implementation "org.springframework.boot:spring-boot-configuration-processor" api "org.springframework.boot:spring-boot-starter-actuator" diff --git a/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt b/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt index 6a0f40ea20..199b8551be 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt @@ -55,7 +55,6 @@ class EmailTestUtil() { val assertEmailTo: AbstractStringAssert<*> get() { - @Suppress("CAST_NEVER_SUCCEEDS") return Assertions.assertThat(messageArgumentCaptor.firstValue.getHeader("To")[0] as String) } } diff --git a/backend/testing/src/main/kotlin/io/tolgee/fixtures/ProjectApiKeyAuthRequestPerformer.kt b/backend/testing/src/main/kotlin/io/tolgee/fixtures/ProjectApiKeyAuthRequestPerformer.kt index bb825d9bfd..28a5fba3fe 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/fixtures/ProjectApiKeyAuthRequestPerformer.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/fixtures/ProjectApiKeyAuthRequestPerformer.kt @@ -12,7 +12,6 @@ import org.springframework.stereotype.Component import org.springframework.test.web.servlet.ResultActions import org.springframework.test.web.servlet.request.MockMvcRequestBuilders -@Suppress("SpringJavaInjectionPointsAutowiringInspection") @Component @org.springframework.context.annotation.Scope("prototype") class ProjectApiKeyAuthRequestPerformer( diff --git a/backend/testing/src/main/kotlin/io/tolgee/fixtures/scopeAssert.kt b/backend/testing/src/main/kotlin/io/tolgee/fixtures/scopeAssert.kt index e462cd84a2..7f5ad0c3a5 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/fixtures/scopeAssert.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/fixtures/scopeAssert.kt @@ -4,9 +4,10 @@ import io.tolgee.model.enums.ProjectPermissionType import io.tolgee.model.enums.Scope import io.tolgee.model.enums.unpack import io.tolgee.testing.assertions.Assertions +import io.tolgee.testing.satisfies import org.assertj.core.api.ObjectArrayAssert -fun ObjectArrayAssert.equalsPermissionType(permissionType: ProjectPermissionType): ObjectArrayAssert? { +fun ObjectArrayAssert.equalsPermissionType(permissionType: ProjectPermissionType): ObjectArrayAssert { return this.satisfies { Assertions.assertThat(it.unpack()).containsExactlyInAnyOrder(*permissionType.availableScopes.unpack()) } diff --git a/backend/testing/src/main/kotlin/io/tolgee/fixtures/statusExpectations.kt b/backend/testing/src/main/kotlin/io/tolgee/fixtures/statusExpectations.kt index fd41a6932b..46202e0c94 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/fixtures/statusExpectations.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/fixtures/statusExpectations.kt @@ -6,6 +6,7 @@ import io.tolgee.constants.Message import io.tolgee.model.enums.ProjectPermissionType import io.tolgee.model.enums.Scope import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.testing.assertions.ErrorResponseAssert import io.tolgee.testing.assertions.MvcResultAssert import net.javacrumbs.jsonunit.assertj.JsonAssert import net.javacrumbs.jsonunit.assertj.assertThatJson @@ -57,7 +58,18 @@ val ResultActions.andAssertThatJson: JsonAssert.ConfigurableJsonAssert fun ResultActions.andAssertThatJson(jsonAssert: JsonAssert.ConfigurableJsonAssert.() -> Unit): ResultActions { tryPrettyPrinting { - jsonAssert(assertThatJson(this.andGetContentAsString)) + jsonAssert( + assertThatJson(this.andGetContentAsString) + // https://github.com/lukas-krecan/JsonUnit?tab=readme-ov-file#numerical-comparison + // We only care about the numeric value, not the precision. Not the business of doing physics (...yet)! :p + .withConfiguration { + it.withNumberComparator { a, b, tolerance -> + val diff = if (a > b) a - b else b - a + diff <= (tolerance ?: BigDecimal.ZERO) + } + }, + ) + this } return this @@ -77,16 +89,20 @@ fun ResultActions.tryPrettyPrinting(fn: ResultActions.() -> ResultActions): Resu } } -val ResultActions.andGetContentAsString +val ResultActions.andGetContentAsString: String get() = this.andReturn().response.getContentAsString(StandardCharsets.UTF_8) -val ResultActions.andAssertError +val ResultActions.andGetContentAsJsonMap + @Suppress("UNCHECKED_CAST") + get() = jacksonObjectMapper().readValue(andGetContentAsString, MutableMap::class.java) as MutableMap + +val ResultActions.andAssertError: ErrorResponseAssert get() = assertThat(this.andReturn()).error() val ResultActions.andPrettyPrint: ResultActions get() = jacksonObjectMapper().let { mapper -> - val parsed = mapper.readValue(this.andGetContentAsString) + val parsed = mapper.readValue(andGetContentAsString) println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(parsed)) return this } diff --git a/backend/testing/src/main/kotlin/io/tolgee/testing/satisfies.kt b/backend/testing/src/main/kotlin/io/tolgee/testing/satisfies.kt new file mode 100644 index 0000000000..977e902946 --- /dev/null +++ b/backend/testing/src/main/kotlin/io/tolgee/testing/satisfies.kt @@ -0,0 +1,34 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.testing + +import org.assertj.core.api.AbstractAssert +import org.assertj.core.api.Condition +import org.assertj.core.api.ThrowingConsumer + +// https://github.com/assertj/assertj/issues/2357 +fun , E> AbstractAssert.satisfies(fn: (actual: E) -> Unit): S { + return satisfies(ThrowingConsumer { fn(it) }) +} + +fun , E> AbstractAssert.satisfiesIf(fn: (actual: E) -> Boolean): S { + return satisfies( + object : Condition() { + override fun matches(value: E): Boolean = fn(value) + }, + ) +} diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 6bdbeb653c..b6c6de1081 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -185,6 +185,8 @@ declare namespace DataCy { "global-list-pagination" | "global-list-search" | "global-loading" | + "global-notifications-button" | + "global-notifications-count" | "global-paginated-list" | "global-plus-button" | "global-search-field" | diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/V2ProjectsInvitationControllerEeTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/V2ProjectsInvitationControllerEeTest.kt index fbbdede5ad..2b470e044f 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/V2ProjectsInvitationControllerEeTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/V2ProjectsInvitationControllerEeTest.kt @@ -59,8 +59,10 @@ class V2ProjectsInvitationControllerEeTest : ProjectAuthControllerTest("/v2/proj translateLanguages = setOf(getLang("en")) }.andIsOk - val invitation = invitationTestUtil.getInvitation(result) - invitation.permission!!.translateLanguages.map { it.tag }.assert.containsExactlyInAnyOrder("en") + executeInNewTransaction { + val invitation = invitationTestUtil.getInvitation(result) + invitation.permission!!.translateLanguages.map { it.tag }.assert.containsExactlyInAnyOrder("en") + } } @Test diff --git a/ee/backend/tests/src/test/resources/application.yaml b/ee/backend/tests/src/test/resources/application.yaml index 620f98ae53..caa22df84f 100644 --- a/ee/backend/tests/src/test/resources/application.yaml +++ b/ee/backend/tests/src/test/resources/application.yaml @@ -83,9 +83,9 @@ springdoc: api-docs: enabled: false -#logging: -# level: +logging: + level: # org.springframework.orm.jpa: DEBUG # org.springframework.transaction: DEBUG # org.hibernate.type: TRACE - + org.jooq.Constants: off diff --git a/file b/file deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gradle.properties b/gradle.properties index 4d270d2960..87aeeae770 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,8 @@ kotlinVersion=1.9.21 -springBootVersion=3.1.5 +springBootVersion=3.2.5 springDocVersion=2.3.0 jjwtVersion=0.11.2 -hibernateVersion=6.4.1.Final +hibernateVersion=6.5.0.Final amazonAwsSdkVersion=2.20.8 springDependencyManagementVersion=1.0.11.RELEASE org.gradle.jvmargs=-Xmx6g -Dkotlin.daemon.jvm.options=-Xmx6g diff --git a/gradle/liquibase.gradle b/gradle/liquibase.gradle index e807bd8ef5..0c9283802e 100644 --- a/gradle/liquibase.gradle +++ b/gradle/liquibase.gradle @@ -35,9 +35,9 @@ ext { "billing_sequence," + "activity_sequence," + "FK9xs5a07fba5yqje5jqm6qrehs," + - "column:textsearchable_.*" + + "column:textsearchable_.*," + "column:enabled_features,"+ - "table:temp_*" + "table:temp_.*" } } } diff --git a/settings.gradle b/settings.gradle index f1edc57be3..5105637de8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -51,12 +51,12 @@ dependencyResolutionManagement { library('jjwtApi', 'io.jsonwebtoken', 'jjwt-api').version(jjwtVersion) library('jjwtImpl', 'io.jsonwebtoken', 'jjwt-impl').version(jjwtVersion) library('jjwtJackson', 'io.jsonwebtoken', 'jjwt-jackson').version(jjwtVersion) - library('assertJCore', 'org.assertj:assertj-core:3.19.0') + library('assertJCore', 'org.assertj:assertj-core:3.24.2') library('springmockk', 'com.ninja-squad:springmockk:3.0.1') library('mockito', 'org.mockito.kotlin:mockito-kotlin:5.0.0') library('commonsCodec', 'commons-codec:commons-codec:1.15') library('icu4j', 'com.ibm.icu:icu4j:74.2') - library('jsonUnitAssert', 'net.javacrumbs.json-unit:json-unit-assertj:2.28.0') + library('jsonUnitAssert', 'net.javacrumbs.json-unit:json-unit-assertj:2.38.0') library('amazonS3', "software.amazon.awssdk:s3:$amazonAwsSdkVersion") library('amazonSTS', "software.amazon.awssdk:sts:$amazonAwsSdkVersion") library('amazonTranslate', "software.amazon.awssdk:translate:$amazonAwsSdkVersion") @@ -64,7 +64,7 @@ dependencyResolutionManagement { library('liquibaseCore', "org.liquibase:liquibase-core:4.26.0") library('liquibaseHibernate', "org.liquibase.ext:liquibase-hibernate6:4.26.0") library('liquibasePicoli', "info.picocli:picocli:4.6.3") - library('hibernateTypes', "io.hypersistence:hypersistence-utils-hibernate-63:3.7.0") + library('hibernateTypes', "io.hypersistence:hypersistence-utils-hibernate-63:3.7.4") library('redissonSpringBootStarter', "org.redisson:redisson-spring-boot-starter:3.26.0") library('postHog', 'com.posthog.java:posthog:1.1.1') library('micrometerPrometheus', "io.micrometer:micrometer-registry-prometheus:1.9.12") @@ -73,6 +73,8 @@ dependencyResolutionManagement { library('slackApiClient', "com.slack.api:slack-api-client:$slackSdkVersion") library('slackApiModelKotlinExtension', "com.slack.api:slack-api-model-kotlin-extension:$slackSdkVersion") library('slackApiClientKotlinExtension', "com.slack.api:slack-api-client-kotlin-extension:$slackSdkVersion") + library('commonsText', 'org.apache.commons:commons-text:1.10.0') + library('springJooq', "org.springframework.boot:spring-boot-starter-jooq:3.3.0") } } } diff --git a/webapp/src/component/RootRouter.tsx b/webapp/src/component/RootRouter.tsx index 3cbe673c60..3e7bba1ce3 100644 --- a/webapp/src/component/RootRouter.tsx +++ b/webapp/src/component/RootRouter.tsx @@ -3,13 +3,13 @@ import { Redirect, Route, Switch } from 'react-router-dom'; import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3'; import { LINKS } from 'tg.constants/links'; +import { PrivateRoute } from './common/PrivateRoute'; +import { NotificationsRouter } from 'tg.views/notifications/NotificationsRouter'; import { ProjectsRouter } from 'tg.views/projects/ProjectsRouter'; import { UserSettingsRouter } from 'tg.views/userSettings/UserSettingsRouter'; import { OrganizationsRouter } from 'tg.views/organizations/OrganizationsRouter'; import { useConfig } from 'tg.globalContext/helpers'; import { AdministrationView } from 'tg.views/administration/AdministrationView'; - -import { PrivateRoute } from './common/PrivateRoute'; import { OrganizationBillingRedirect } from './security/OrganizationBillingRedirect'; import { RequirePreferredOrganization } from '../RequirePreferredOrganization'; import { HelpMenu } from './HelpMenu'; @@ -17,9 +17,7 @@ import { PublicOnlyRoute } from './common/PublicOnlyRoute'; import { PreferredOrganizationRedirect } from './security/PreferredOrganizationRedirect'; import { RootView } from 'tg.views/RootView'; -const LoginRouter = React.lazy( - () => import(/* webpackChunkName: "login" */ './security/Login/LoginRouter') -); +const LoginRouter = React.lazy(() => import('./security/Login/LoginRouter')); const SlackConnectView = React.lazy( () => @@ -35,30 +33,16 @@ const SlackConnectedView = React.lazy( ) ); -const SignUpView = React.lazy( - () => - import( - /* webpackChunkName: "sign-up-view" */ './security/SignUp/SignUpView' - ) -); +const SignUpView = React.lazy(() => import('./security/SignUp/SignUpView')); const PasswordResetSetView = React.lazy( - () => - import( - /* webpackChunkName: "reset-password-set-view" */ './security/ResetPasswordSetView' - ) + () => import('./security/ResetPasswordSetView') ); const PasswordResetView = React.lazy( - () => - import( - /* webpackChunkName: "reset-password-view" */ './security/ResetPasswordView' - ) + () => import('./security/ResetPasswordView') ); const AcceptInvitationHandler = React.lazy( - () => - import( - /* webpackChunkName: "accept-invitation-handler" */ './security/AcceptInvitationHandler' - ) + () => import('./security/AcceptInvitationHandler') ); const RecaptchaProvider: FC = (props) => { @@ -114,6 +98,9 @@ export const RootRouter = () => ( + + + diff --git a/webapp/src/component/activity/groups/ActivityGroupItem.tsx b/webapp/src/component/activity/groups/ActivityGroupItem.tsx new file mode 100644 index 0000000000..fbae684cbb --- /dev/null +++ b/webapp/src/component/activity/groups/ActivityGroupItem.tsx @@ -0,0 +1,23 @@ +import React, { FC } from 'react'; +import { components } from 'tg.service/apiSchema.generated'; +import { CreateProjectActivityGroup } from './groupTypeComponents/CreateProjectActivityGroup'; +import { CollapsibleActivityGroup } from './groupTypeComponents/CollapsibleActivityGroup'; +import { CreateKeysActivityGroup } from './groupTypeComponents/CreateKeysActivityGroup'; +import { SetTranslationsActivityGroup } from './groupTypeComponents/SetTranslationsActivityGroup'; + +export const ActivityGroupItem: FC<{ + item: components['schemas']['ActivityGroupModel']; +}> = (props) => { + switch (props.item.type) { + case 'CREATE_PROJECT': + return ; + case 'CREATE_KEY': + return ; + case 'SET_TRANSLATIONS': + return ; + default: + return ( + {props.item.type} + ); + } +}; diff --git a/webapp/src/component/activity/groups/SimpleTableExpandedContent.tsx b/webapp/src/component/activity/groups/SimpleTableExpandedContent.tsx new file mode 100644 index 0000000000..971bc50d02 --- /dev/null +++ b/webapp/src/component/activity/groups/SimpleTableExpandedContent.tsx @@ -0,0 +1,68 @@ +import React, { FC } from 'react'; +import { UseQueryResult } from 'react-query'; +import { PaginatedHateoasList } from '../../common/list/PaginatedHateoasList'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from '@mui/material'; + +type Loadable = UseQueryResult<{ + _embedded?: { items?: Record[] }; +}>; +type SimpleTableExpandedContentProps = { + getData: (page: number) => Loadable; +}; + +export const SimpleTableExpandedContent: FC = ( + props +) => { + const [page, setPage] = React.useState(0); + + const loadable = props.getData(page); + + const fields = getFields(loadable); + + const Table: FC = (props) => { + return {props.children}; + }; + + return ( + ( + + {fields.map((f, idx) => ( + {i[f]} + ))} + + )} + onPageChange={(p) => setPage(p)} + loadable={loadable} + /> + ); +}; + +const TheTable: FC<{ headItems: string[] }> = (props) => { + return ( + + + {props.headItems.map((item, idx) => ( + {item} + ))} + + {props.children} +
+ ); +}; + +function getFields(loadable: Loadable) { + const fields = new Set(); + loadable.data?._embedded?.items?.forEach((i) => { + Object.keys(i).forEach((k) => fields.add(k)); + }); + + return [...fields]; +} diff --git a/webapp/src/component/activity/groups/common/ActivityGroupMentionedLanguages.tsx b/webapp/src/component/activity/groups/common/ActivityGroupMentionedLanguages.tsx new file mode 100644 index 0000000000..fb990b1c19 --- /dev/null +++ b/webapp/src/component/activity/groups/common/ActivityGroupMentionedLanguages.tsx @@ -0,0 +1,27 @@ +import { FC } from 'react'; +import { LanguageIconWithTooltip } from '../../../languages/LanguageIconWithTooltip'; +import { useProjectLanguages } from 'tg.hooks/useProjectLanguages'; +import { Box } from '@mui/material'; + +type ActivityGroupMentionedLanguagesProps = { + mentionedLanguageIds: number[]; +}; + +export const ActivityGroupMentionedLanguages: FC< + ActivityGroupMentionedLanguagesProps +> = (props) => { + const languages = useProjectLanguages(); + + //filter only mentioned + const used = languages.filter((l) => + props.mentionedLanguageIds.includes(l.id) + ); + + return ( + + {used.map((l, i) => ( + + ))} + + ); +}; diff --git a/webapp/src/component/activity/groups/groupTypeComponents/CollapsibleActivityGroup.tsx b/webapp/src/component/activity/groups/groupTypeComponents/CollapsibleActivityGroup.tsx new file mode 100644 index 0000000000..e9e1346743 --- /dev/null +++ b/webapp/src/component/activity/groups/groupTypeComponents/CollapsibleActivityGroup.tsx @@ -0,0 +1,24 @@ +import React, { FC } from 'react'; +import { Box, Button } from '@mui/material'; + +export const CollapsibleActivityGroup: FC<{ + expandedChildren?: React.ReactNode; +}> = (props) => { + const [expanded, setExpanded] = React.useState(false); + + const expandedContent = expanded + ? props.expandedChildren + ? props.expandedChildren + : false + : null; + + return ( + + {props.children} + {props.expandedChildren !== undefined && ( + + )} + {expandedContent && {expandedContent}} + + ); +}; diff --git a/webapp/src/component/activity/groups/groupTypeComponents/CreateKeysActivityGroup.tsx b/webapp/src/component/activity/groups/groupTypeComponents/CreateKeysActivityGroup.tsx new file mode 100644 index 0000000000..3aeee93c5d --- /dev/null +++ b/webapp/src/component/activity/groups/groupTypeComponents/CreateKeysActivityGroup.tsx @@ -0,0 +1,37 @@ +import React, { FC } from 'react'; +import { components } from 'tg.service/apiSchema.generated'; +import { CollapsibleActivityGroup } from './CollapsibleActivityGroup'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { useProject } from 'tg.hooks/useProject'; +import { SimpleTableExpandedContent } from '../SimpleTableExpandedContent'; + +type Group = components['schemas']['ActivityGroupCreateKeyModel']; + +export const CreateKeysActivityGroup: FC<{ + group: Group; +}> = ({ group }) => { + return ( + } + > + {group.author?.name} Created {group.data?.keyCount} keys{' '} + + ); +}; + +const ExpandedContent: FC<{ group: Group }> = (props) => { + const project = useProject(); + const getData = (page: number) => + useApiQuery({ + url: '/v2/projects/{projectId}/activity/group-items/create-key/{groupId}', + method: 'get', + path: { projectId: project.id, groupId: props.group.id }, + query: { + page: page, + size: 20, + }, + }); + return ( + + ); +}; diff --git a/webapp/src/component/activity/groups/groupTypeComponents/CreateProjectActivityGroup.tsx b/webapp/src/component/activity/groups/groupTypeComponents/CreateProjectActivityGroup.tsx new file mode 100644 index 0000000000..5409b4efd2 --- /dev/null +++ b/webapp/src/component/activity/groups/groupTypeComponents/CreateProjectActivityGroup.tsx @@ -0,0 +1,21 @@ +import React, { FC } from 'react'; +import { components } from 'tg.service/apiSchema.generated'; +import { CollapsibleActivityGroup } from './CollapsibleActivityGroup'; + +type Group = components['schemas']['ActivityGroupCreateProjectModel']; + +export const CreateProjectActivityGroup: FC<{ + group: Group; +}> = ({ group }) => { + return ( + } + > + {group.author?.name} Created project {group.data?.name} + + ); +}; + +const ExpandedContent: FC<{ group: Group }> = (props) => { + return
{JSON.stringify(props.group, null, 2)}
; +}; diff --git a/webapp/src/component/activity/groups/groupTypeComponents/SetTranslationsActivityGroup.tsx b/webapp/src/component/activity/groups/groupTypeComponents/SetTranslationsActivityGroup.tsx new file mode 100644 index 0000000000..568db378b3 --- /dev/null +++ b/webapp/src/component/activity/groups/groupTypeComponents/SetTranslationsActivityGroup.tsx @@ -0,0 +1,35 @@ +import React, { FC } from 'react'; +import { components } from 'tg.service/apiSchema.generated'; +import { CollapsibleActivityGroup } from './CollapsibleActivityGroup'; + +type Group = components['schemas']['ActivityGroupSetTranslationsModel']; + +export const SetTranslationsActivityGroup: FC<{ + group: Group; +}> = ({ group }) => { + return ( + } + > + {group.author?.name} Translated {group.data?.translationCount} strings{' '} + + ); +}; + +const ExpandedContent: FC<{ group: Group }> = (props) => { + // const project = useProject(); + // const getData = (page: number) => + // useApiQuery({ + // url: '/v2/projects/{projectId}/activity/group-items/create-key/{groupId}', + // method: 'get', + // path: { projectId: project.id, groupId: props.group.id }, + // query: { + // page: page, + // size: 20, + // }, + // }); + // return ( + // + // ); + return null; +}; diff --git a/webapp/src/component/activity/types.tsx b/webapp/src/component/activity/types.tsx index 752e0bee92..a4da2c75de 100644 --- a/webapp/src/component/activity/types.tsx +++ b/webapp/src/component/activity/types.tsx @@ -78,7 +78,7 @@ export type FieldOptions = boolean | FieldOptionsObj; export type LanguageReferenceType = { tag: string; name: string; - flagEmoji: string; + flagEmoji?: string; }; export type KeyReferenceData = { diff --git a/webapp/src/component/common/avatar/useAutoAvatarImgSrc.tsx b/webapp/src/component/common/avatar/useAutoAvatarImgSrc.tsx index b43e4c8b42..c4e6be98a5 100644 --- a/webapp/src/component/common/avatar/useAutoAvatarImgSrc.tsx +++ b/webapp/src/component/common/avatar/useAutoAvatarImgSrc.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { AutoAvatarProps } from './AutoAvatar'; +import { Buffer } from 'buffer'; function getInitialsAvatarSvg(ownerName: string, size: number, light: boolean) { return Promise.all([ diff --git a/webapp/src/component/languages/LanguageIconWithTooltip.tsx b/webapp/src/component/languages/LanguageIconWithTooltip.tsx new file mode 100644 index 0000000000..e290fed45e --- /dev/null +++ b/webapp/src/component/languages/LanguageIconWithTooltip.tsx @@ -0,0 +1,20 @@ +import React, { FC } from 'react'; +import { Tooltip } from '@mui/material'; +import { CircledLanguageIcon } from './CircledLanguageIcon'; +import { LanguageReferenceType } from '../activity/types'; + +type LanguageIconWithTooltipProps = { + l: LanguageReferenceType; +}; + +export const LanguageIconWithTooltip: FC = ({ + l, +}) => { + return ( + + + + + + ); +}; diff --git a/webapp/src/component/layout/TopBar/NotificationBell.tsx b/webapp/src/component/layout/TopBar/NotificationBell.tsx new file mode 100644 index 0000000000..268a444d39 --- /dev/null +++ b/webapp/src/component/layout/TopBar/NotificationBell.tsx @@ -0,0 +1,62 @@ +/** + * Copyright (C) 2024 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, IconButton, styled } from '@mui/material'; +import { Notifications } from '@mui/icons-material'; +import { Link } from 'react-router-dom'; +import { LINKS } from 'tg.constants/links'; + +const StyledIconButton = styled(IconButton)` + width: 40px; + height: 40px; + position: relative; +`; + +const StyledNotificationCount = styled(Box)` + border: 3px ${({ theme }) => theme.palette.navbar.background} solid; + border-radius: 0.6rem; + background-color: ${({ theme }) => theme.palette.error.main}; + color: ${({ theme }) => theme.palette.error.contrastText}; + height: 1.2rem; + min-width: 1.2rem; + padding: 0 2px; + font-size: 0.6rem; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + bottom: 0.2rem; + right: 0.2rem; +`; + +export const NotificationBell: React.FC = () => { + return ( + + + + 1 + + + ); +}; diff --git a/webapp/src/component/layout/TopBar/TopBar.tsx b/webapp/src/component/layout/TopBar/TopBar.tsx index 7742d16ec5..a3a0936d78 100644 --- a/webapp/src/component/layout/TopBar/TopBar.tsx +++ b/webapp/src/component/layout/TopBar/TopBar.tsx @@ -16,6 +16,7 @@ import { UserMenu } from '../../security/UserMenu/UserMenu'; import { AdminInfo } from './AdminInfo'; import { QuickStartTopBarButton } from '../QuickStartGuide/QuickStartTopBarButton'; import { LanguageMenu } from 'tg.component/layout/TopBar/LanguageMenu'; +import { NotificationBell } from 'tg.component/layout/TopBar/NotificationBell'; export const TOP_BAR_HEIGHT = 52; @@ -126,7 +127,12 @@ export const TopBar: React.FC = ({ {isEmailVerified && } {!user && } - {user && } + {user && ( + <> + + + + )} ); diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 603c77e589..5ab44a5765 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -161,6 +161,18 @@ export class LINKS { 'disable-mfa' ); + /** + * Notifications + */ + + static NOTIFICATIONS = Link.ofRoot('notifications'); + + static NOTIFICATIONS_INBOX = Link.ofParent(LINKS.NOTIFICATIONS, 'inbox'); + + static NOTIFICATIONS_UNREAD = Link.ofParent(LINKS.NOTIFICATIONS, 'unread'); + + static NOTIFICATIONS_DONE = Link.ofParent(LINKS.NOTIFICATIONS, 'done'); + /** * Administration */ @@ -300,7 +312,7 @@ export class LINKS { 'websockets' ); - static ACTIVITY_PREVIEW = Link.ofParent(LINKS.PROJECT, 'activity'); + static PROJECT_ACTIVITY_GROUPS = Link.ofParent(LINKS.PROJECT, 'activity'); static PROJECT_DASHBOARD = LINKS.PROJECT; diff --git a/webapp/src/custom.d.ts b/webapp/src/custom.d.ts index 9d303ab81f..b1699cfa70 100644 --- a/webapp/src/custom.d.ts +++ b/webapp/src/custom.d.ts @@ -1,9 +1,9 @@ -import API from '@openreplay/tracker'; import { PaletteColor } from '@mui/material/styles'; import { PaletteColorOptions } from '@mui/material'; import { Activity, Cell, + colors, Editor, Emphasis, ExampleBanner, @@ -96,9 +96,10 @@ declare module '@mui/material/Button' { } } -declare global { - interface Window { - openReplayTracker?: API; +declare module '@mui/material/ButtonBase' { + interface ButtonBaseOwnProps> + extends TProps { + component?: T; } } diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 0ad67c7fbc..869060a177 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -270,6 +270,15 @@ export interface paths { put: operations["uploadAvatar_2"]; delete: operations["removeAvatar_2"]; }; + "/v2/notifications/preferences/project/{id}": { + get: operations["getPerProjectPreferences"]; + put: operations["updatePerProjectPreferences"]; + delete: operations["deletePerProjectPreferences"]; + }; + "/v2/notifications/preferences/global": { + get: operations["getGlobalPreferences"]; + put: operations["updateGlobalPreferences"]; + }; "/v2/ee-license/set-license-key": { put: operations["setLicenseKey"]; }; @@ -508,6 +517,27 @@ export interface paths { */ post: operations["connectWorkspace"]; }; + "/v2/notifications/unmark-as-done": { + post: operations["unmarkNotificationsAsDone"]; + }; + "/v2/notifications/preferences/project/{id}/subscribe": { + post: operations["subscribeToProject"]; + }; + "/v2/notifications/mark-as-unread": { + post: operations["markNotificationsAsUnread"]; + }; + "/v2/notifications/mark-as-read": { + post: operations["markNotificationsAsRead"]; + }; + "/v2/notifications/mark-as-read/all": { + post: operations["markAllNotificationsAsRead"]; + }; + "/v2/notifications/mark-as-done": { + post: operations["markNotificationsAsDone"]; + }; + "/v2/notifications/mark-as-done/all": { + post: operations["markAllNotificationsAsDone"]; + }; "/v2/image-upload": { post: operations["upload"]; }; @@ -617,6 +647,13 @@ export interface paths { "/v2/projects/{projectId}/activity/revisions/{revisionId}": { get: operations["getSingleRevision"]; }; + "/v2/projects/{projectId}/activity/groups": { + /** This endpoints returns the activity grouped by time windows so it's easier to read on the frontend. */ + get: operations["getActivityGroups"]; + }; + "/v2/projects/{projectId}/activity/group-items/create-key/{groupId}": { + get: operations["getCreateKeyItems"]; + }; "/v2/projects/{projectId}/activity": { get: operations["getActivity"]; }; @@ -755,6 +792,12 @@ export interface paths { /** Returns all organization projects the user has access to */ get: operations["getAllProjects_1"]; }; + "/v2/notifications": { + get: operations["getNotifications"]; + }; + "/v2/notifications/preferences": { + get: operations["getAllPreferences"]; + }; "/v2/invitations/{code}/accept": { get: operations["acceptInvitation"]; }; @@ -1062,7 +1105,8 @@ export interface components { | "tolgee_account_already_connected" | "slack_not_configured" | "slack_workspace_already_connected" - | "slack_connection_error"; + | "slack_connection_error" + | "email_verification_code_not_valid"; params?: { [key: string]: unknown }[]; }; ErrorResponseBody: { @@ -1135,29 +1179,6 @@ export interface components { | "SERVER_ADMIN"; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; - /** - * @deprecated - * @description Deprecated (use translateLanguageIds). - * - * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. - * @example 200001,200004 - */ - permittedLanguageIds?: number[]; - /** - * @description List of languages user can translate to. If null, all languages editing is permitted. - * @example 200001,200004 - */ - translateLanguageIds?: number[]; - /** - * @description List of languages user can view. If null, all languages view is permitted. - * @example 200001,200004 - */ - viewLanguageIds?: number[]; - /** - * @description List of languages user can change state to. If null, changing state of all language values is permitted. - * @example 200001,200004 - */ - stateChangeLanguageIds?: number[]; /** * @description Granted scopes to the user. When user has type permissions, this field contains permission scopes of the type. * @example KEYS_EDIT,TRANSLATIONS_VIEW @@ -1190,6 +1211,29 @@ export interface components { | "content-delivery.publish" | "webhooks.manage" )[]; + /** + * @description List of languages user can view. If null, all languages view is permitted. + * @example 200001,200004 + */ + viewLanguageIds?: number[]; + /** + * @description List of languages user can translate to. If null, all languages editing is permitted. + * @example 200001,200004 + */ + translateLanguageIds?: number[]; + /** + * @description List of languages user can change state to. If null, changing state of all language values is permitted. + * @example 200001,200004 + */ + stateChangeLanguageIds?: number[]; + /** + * @deprecated + * @description Deprecated (use translateLanguageIds). + * + * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. + * @example 200001,200004 + */ + permittedLanguageIds?: number[]; }; LanguageModel: { /** Format: int64 */ @@ -1995,7 +2039,7 @@ export interface components { /** Format: int64 */ id: number; username: string; - name?: string; + name: string; avatar?: components["schemas"]["Avatar"]; deleted: boolean; }; @@ -2156,7 +2200,6 @@ export interface components { token: string; /** Format: int64 */ id: number; - description: string; /** Format: int64 */ createdAt: number; /** Format: int64 */ @@ -2165,6 +2208,7 @@ export interface components { expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; + description: string; }; SetOrganizationRoleDto: { roleType: "MEMBER" | "OWNER"; @@ -2212,6 +2256,23 @@ export interface components { invitedUserName?: string; invitedUserEmail?: string; }; + NotificationPreferencesDto: { + /** @description List of notification types the user does not want to receive. */ + disabledNotifications: ( + | "ACTIVITY_LANGUAGES_CREATED" + | "ACTIVITY_KEYS_CREATED" + | "ACTIVITY_KEYS_UPDATED" + | "ACTIVITY_KEYS_SCREENSHOTS_UPLOADED" + | "ACTIVITY_SOURCE_STRINGS_UPDATED" + | "ACTIVITY_TRANSLATIONS_UPDATED" + | "ACTIVITY_TRANSLATION_OUTDATED" + | "ACTIVITY_TRANSLATION_REVIEWED" + | "ACTIVITY_TRANSLATION_UNREVIEWED" + | "ACTIVITY_NEW_COMMENTS" + | "ACTIVITY_COMMENTS_MENTION" + | "BATCH_JOB_ERRORED" + )[]; + }; SetLicenseKeyDto: { licenseKey: string; }; @@ -2302,17 +2363,17 @@ export interface components { key: string; /** Format: int64 */ id: number; - projectName: string; - userFullName?: string; - description: string; username?: string; - /** Format: int64 */ - projectId: number; + scopes: string[]; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ + projectId: number; + /** Format: int64 */ lastUsedAt?: number; - scopes: string[]; + description: string; + projectName: string; + userFullName?: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -2863,7 +2924,8 @@ export interface components { | "tolgee_account_already_connected" | "slack_not_configured" | "slack_workspace_already_connected" - | "slack_connection_error"; + | "slack_connection_error" + | "email_verification_code_not_valid"; params?: { [key: string]: unknown }[]; }; UntagKeysRequest: { @@ -3416,6 +3478,8 @@ export interface components { languageTag?: string; eeSubscription?: components["schemas"]["EeSubscriptionModel"]; announcement?: components["schemas"]["AnnouncementDto"]; + /** Format: int32 */ + unreadNotifications?: number; }; MtServiceDTO: { enabled: boolean; @@ -3466,18 +3530,18 @@ export interface components { name: string; /** Format: int64 */ id: number; - basePermissions: components["schemas"]["PermissionModel"]; + avatar?: components["schemas"]["Avatar"]; /** @example btforg */ slug: string; /** @example This is a beautiful organization full of beautiful and clever people */ description?: string; + basePermissions: components["schemas"]["PermissionModel"]; /** * @description The role of currently authorized user. * * Can be null when user has direct access to one of the projects owned by the organization. */ currentUserRole?: "MEMBER" | "OWNER"; - avatar?: components["schemas"]["Avatar"]; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3614,20 +3678,20 @@ export interface components { name: string; /** Format: int64 */ id: number; - namespace?: string; + translation?: string; description?: string; + namespace?: string; baseTranslation?: string; - translation?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; name: string; /** Format: int64 */ id: number; - namespace?: string; + translation?: string; description?: string; + namespace?: string; baseTranslation?: string; - translation?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -3705,63 +3769,1147 @@ export interface components { avatar?: components["schemas"]["Avatar"]; deleted: boolean; }; - ProjectActivityModel: { - /** Format: int64 */ - revisionId: number; + ProjectActivityModel: + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "UNKNOWN"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "SET_TRANSLATION_STATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: { + Translation?: { + entityClass?: string; + /** Format: int64 */ + entityId?: number; + description?: { [key: string]: { [key: string]: unknown } }; + modifications?: { + text?: { + old?: string; + new?: string; + }; + state?: { + old?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + new?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + }; + auto?: { + old?: boolean; + new?: boolean; + }; + mtProvider?: { + old?: + | "GOOGLE" + | "AWS" + | "DEEPL" + | "AZURE" + | "BAIDU" + | "TOLGEE"; + new?: + | "GOOGLE" + | "AWS" + | "DEEPL" + | "AZURE" + | "BAIDU" + | "TOLGEE"; + }; + outdated?: { + old?: boolean; + new?: boolean; + }; + }; + relations?: { + [ + key: string + ]: components["schemas"]["ExistenceEntityDescription"]; + }; + exists?: boolean; + }; + }; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "SET_TRANSLATIONS"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: { + Translation?: { + entityClass?: string; + /** Format: int64 */ + entityId?: number; + description?: { [key: string]: { [key: string]: unknown } }; + modifications?: { + text?: { + old?: string; + new?: string; + }; + state?: { + old?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + new?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + }; + auto?: { + old?: boolean; + new?: boolean; + }; + mtProvider?: { + old?: + | "GOOGLE" + | "AWS" + | "DEEPL" + | "AZURE" + | "BAIDU" + | "TOLGEE"; + new?: + | "GOOGLE" + | "AWS" + | "DEEPL" + | "AZURE" + | "BAIDU" + | "TOLGEE"; + }; + outdated?: { + old?: boolean; + new?: boolean; + }; + }; + relations?: { + [ + key: string + ]: components["schemas"]["ExistenceEntityDescription"]; + }; + exists?: boolean; + }; + }; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "DISMISS_AUTO_TRANSLATED_STATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: { + Translation?: { + entityClass?: string; + /** Format: int64 */ + entityId?: number; + description?: { [key: string]: { [key: string]: unknown } }; + modifications?: { + text?: { + old?: string; + new?: string; + }; + state?: { + old?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + new?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + }; + auto?: { + old?: boolean; + new?: boolean; + }; + mtProvider?: { + old?: + | "GOOGLE" + | "AWS" + | "DEEPL" + | "AZURE" + | "BAIDU" + | "TOLGEE"; + new?: + | "GOOGLE" + | "AWS" + | "DEEPL" + | "AZURE" + | "BAIDU" + | "TOLGEE"; + }; + outdated?: { + old?: boolean; + new?: boolean; + }; + }; + relations?: { + [ + key: string + ]: components["schemas"]["ExistenceEntityDescription"]; + }; + exists?: boolean; + }; + }; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "SET_OUTDATED_FLAG"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "TRANSLATION_COMMENT_ADD"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "TRANSLATION_COMMENT_DELETE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "TRANSLATION_COMMENT_EDIT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "TRANSLATION_COMMENT_SET_STATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "SCREENSHOT_DELETE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "SCREENSHOT_ADD"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "KEY_TAGS_EDIT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "KEY_NAME_EDIT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "KEY_DELETE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CREATE_KEY"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: { + Key?: { + entityClass?: string; + /** Format: int64 */ + entityId?: number; + description?: { [key: string]: { [key: string]: unknown } }; + modifications?: { + name?: { + old?: unknown; + new?: string; + }; + isPlural?: { + old?: unknown; + new?: boolean; + }; + pluralArgName?: { + old?: unknown; + new?: string; + }; + namespace?: { + old?: unknown; + new?: { + entityClass: string; + /** Format: int64 */ + entityId: number; + data: { + name?: string; + }; + }; + }; + }; + relations?: { + [ + key: string + ]: components["schemas"]["ExistenceEntityDescription"]; + }; + exists?: boolean; + }; + KeyMeta?: { + entityClass?: string; + /** Format: int64 */ + entityId?: number; + description?: { [key: string]: { [key: string]: unknown } }; + modifications?: { + description?: { + old?: unknown; + new?: string; + }; + custom?: { + old?: unknown; + new?: { [key: string]: { [key: string]: unknown } }; + }; + tags?: { + old?: unknown; + new?: { + entityClass: string; + /** Format: int64 */ + entityId: number; + data: { [key: string]: unknown }; + }; + }; + }; + relations?: { + [ + key: string + ]: components["schemas"]["ExistenceEntityDescription"]; + }; + exists?: boolean; + }; + }; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "COMPLEX_EDIT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "IMPORT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CREATE_LANGUAGE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_LANGUAGE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "DELETE_LANGUAGE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "HARD_DELETE_LANGUAGE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CREATE_PROJECT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_PROJECT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "NAMESPACE_EDIT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_PRE_TRANSLATE_BY_TM"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_MACHINE_TRANSLATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "AUTO_TRANSLATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_CLEAR_TRANSLATIONS"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_COPY_TRANSLATIONS"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_SET_TRANSLATION_STATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_TAG_KEYS"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_UNTAG_KEYS"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_SET_KEYS_NAMESPACE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "AUTOMATION"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_DELIVERY_CONFIG_CREATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_DELIVERY_CONFIG_UPDATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_DELIVERY_CONFIG_DELETE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_STORAGE_CREATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_STORAGE_UPDATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_STORAGE_DELETE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "WEBHOOK_CONFIG_CREATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "WEBHOOK_CONFIG_UPDATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "WEBHOOK_CONFIG_DELETE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + } + | { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "COMPLEX_TAG_OPERATION"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ActivityGroupModel: + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "SET_TRANSLATION_STATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "REVIEW"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "SET_BASE_TRANSLATION"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "SET_TRANSLATIONS"; + author?: components["schemas"]["SimpleUserAccountModel"]; + data?: { + /** Format: int32 */ + translationCount: number; + }; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "DISMISS_AUTO_TRANSLATED_STATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "SET_OUTDATED_FLAG"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "ADD_TRANSLATION_COMMENT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "DELETE_TRANSLATION_COMMENT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_TRANSLATION_COMMENT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "SET_TRANSLATION_COMMENT_STATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "DELETE_SCREENSHOT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "ADD_SCREENSHOT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_KEY_TAGS"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_KEY_NAME"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "DELETE_KEY"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CREATE_KEY"; + author?: components["schemas"]["SimpleUserAccountModel"]; + data?: { + /** Format: int32 */ + keyCount: number; + }; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "IMPORT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CREATE_LANGUAGE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_LANGUAGE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "DELETE_LANGUAGE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CREATE_PROJECT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + data?: { + /** Format: int64 */ + id: number; + name: string; + languages: components["schemas"]["ActivityGroupLanguageModel"][]; + description?: string; + }; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_PROJECT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "NAMESPACE_EDIT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_PRE_TRANSLATE_BY_TM"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_MACHINE_TRANSLATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "AUTO_TRANSLATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_CLEAR_TRANSLATIONS"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_COPY_TRANSLATIONS"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_SET_TRANSLATION_STATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_DELIVERY_CONFIG_CREATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_DELIVERY_CONFIG_UPDATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_DELIVERY_CONFIG_DELETE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_STORAGE_CREATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_STORAGE_UPDATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_STORAGE_DELETE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "WEBHOOK_CONFIG_CREATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "WEBHOOK_CONFIG_UPDATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + } + | { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "WEBHOOK_CONFIG_DELETE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + PagedModelActivityGroupModel: { + _embedded?: { + groups?: components["schemas"]["ActivityGroupModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; + CreateKeyGroupItemModel: { + /** + * Format: int64 + * @description Id of key record + */ + id: number; + /** + * @description Name of key + * @example this_is_super_key + */ + name: string; + /** + * @description Namespace of key + * @example homepage + */ + namespace?: string; + /** @description If key is pluralized. If it will be reflected in the editor */ + isPlural: boolean; + /** @description The argument name for the plural */ + pluralArgName?: string; + /** @description The base translation value entered when key was created */ + baseTranslationValue?: string; + tags: string[]; + /** + * @description Description of key + * @example This key is used on homepage. It's a label of sign up button. + */ + description?: string; + /** @description Custom values of the key */ + custom?: { [key: string]: { [key: string]: unknown } }; /** Format: int64 */ - timestamp: number; - type: - | "UNKNOWN" - | "SET_TRANSLATION_STATE" - | "SET_TRANSLATIONS" - | "DISMISS_AUTO_TRANSLATED_STATE" - | "SET_OUTDATED_FLAG" - | "TRANSLATION_COMMENT_ADD" - | "TRANSLATION_COMMENT_DELETE" - | "TRANSLATION_COMMENT_EDIT" - | "TRANSLATION_COMMENT_SET_STATE" - | "SCREENSHOT_DELETE" - | "SCREENSHOT_ADD" - | "KEY_TAGS_EDIT" - | "KEY_NAME_EDIT" - | "KEY_DELETE" - | "CREATE_KEY" - | "COMPLEX_EDIT" - | "IMPORT" - | "CREATE_LANGUAGE" - | "EDIT_LANGUAGE" - | "DELETE_LANGUAGE" - | "HARD_DELETE_LANGUAGE" - | "CREATE_PROJECT" - | "EDIT_PROJECT" - | "NAMESPACE_EDIT" - | "BATCH_PRE_TRANSLATE_BY_TM" - | "BATCH_MACHINE_TRANSLATE" - | "AUTO_TRANSLATE" - | "BATCH_CLEAR_TRANSLATIONS" - | "BATCH_COPY_TRANSLATIONS" - | "BATCH_SET_TRANSLATION_STATE" - | "BATCH_TAG_KEYS" - | "BATCH_UNTAG_KEYS" - | "BATCH_SET_KEYS_NAMESPACE" - | "AUTOMATION" - | "CONTENT_DELIVERY_CONFIG_CREATE" - | "CONTENT_DELIVERY_CONFIG_UPDATE" - | "CONTENT_DELIVERY_CONFIG_DELETE" - | "CONTENT_STORAGE_CREATE" - | "CONTENT_STORAGE_UPDATE" - | "CONTENT_STORAGE_DELETE" - | "WEBHOOK_CONFIG_CREATE" - | "WEBHOOK_CONFIG_UPDATE" - | "WEBHOOK_CONFIG_DELETE" - | "COMPLEX_TAG_OPERATION"; - author?: components["schemas"]["ProjectActivityAuthorModel"]; - modifiedEntities?: { - [key: string]: components["schemas"]["ModifiedEntityModel"][]; + baseLanguageId?: number; + }; + PagedModelCreateKeyGroupItemModel: { + _embedded?: { + items?: components["schemas"]["CreateKeyGroupItemModel"][]; }; - meta?: { [key: string]: { [key: string]: unknown } }; - counts?: { [key: string]: number }; - params?: { [key: string]: unknown }; + page?: components["schemas"]["PageMetadata"]; }; PagedModelProjectActivityModel: { _embedded?: { @@ -4173,7 +5321,6 @@ export interface components { user: components["schemas"]["SimpleUserAccountModel"]; /** Format: int64 */ id: number; - description: string; /** Format: int64 */ createdAt: number; /** Format: int64 */ @@ -4182,6 +5329,7 @@ export interface components { expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; + description: string; }; PagedModelOrganizationModel: { _embedded?: { @@ -4302,6 +5450,44 @@ export interface components { projectsWithDirectPermission: components["schemas"]["SimpleProjectModel"][]; avatar?: components["schemas"]["Avatar"]; }; + SimpleModifiedEntityView: { + entityClass: string; + /** Format: int64 */ + entityId: number; + exists?: boolean; + modifications: { + [key: string]: components["schemas"]["PropertyModification"]; + }; + description?: { [key: string]: { [key: string]: unknown } }; + describingRelations?: { + [key: string]: components["schemas"]["ExistenceEntityDescription"]; + }; + }; + UserNotificationModel: { + /** Format: int64 */ + id: number; + type: + | "ACTIVITY_LANGUAGES_CREATED" + | "ACTIVITY_KEYS_CREATED" + | "ACTIVITY_KEYS_UPDATED" + | "ACTIVITY_KEYS_SCREENSHOTS_UPLOADED" + | "ACTIVITY_SOURCE_STRINGS_UPDATED" + | "ACTIVITY_TRANSLATIONS_UPDATED" + | "ACTIVITY_TRANSLATION_OUTDATED" + | "ACTIVITY_TRANSLATION_REVIEWED" + | "ACTIVITY_TRANSLATION_UNREVIEWED" + | "ACTIVITY_NEW_COMMENTS" + | "ACTIVITY_COMMENTS_MENTION" + | "BATCH_JOB_ERRORED"; + project?: components["schemas"]["SimpleProjectModel"]; + batchJob?: components["schemas"]["BatchJobModel"]; + modifiedEntities?: components["schemas"]["SimpleModifiedEntityView"][]; + unread: boolean; + /** Format: date-time */ + markedDoneAt?: string; + /** Format: date-time */ + lastUpdated: string; + }; ApiKeyWithLanguagesModel: { /** * @deprecated @@ -4310,17 +5496,17 @@ export interface components { permittedLanguageIds?: number[]; /** Format: int64 */ id: number; - projectName: string; - userFullName?: string; - description: string; username?: string; - /** Format: int64 */ - projectId: number; + scopes: string[]; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ + projectId: number; + /** Format: int64 */ lastUsedAt?: number; - scopes: string[]; + description: string; + projectName: string; + userFullName?: string; }; PagedModelUserAccountModel: { _embedded?: { @@ -4346,44 +5532,3010 @@ export interface components { /** @description IDs of keys to delete */ ids: number[]; }; - }; -} - -export interface operations { - /** Returns information about currently authenticated user. */ - getInfo_2: { - responses: { - /** OK */ - 200: { - content: { - "application/json": components["schemas"]["PrivateUserAccountModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Unauthorized */ - 401: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Forbidden */ - 403: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; + ActivityDescribingEntity: { + activityRevision: components["schemas"]["ActivityRevision"]; + entityClass: string; + /** Format: int64 */ + entityId: number; + data: { [key: string]: { [key: string]: unknown } }; + describingRelations?: { + [key: string]: components["schemas"]["EntityDescriptionRef"]; }; - /** Not Found */ + }; + ActivityGroup: { + type: + | "SET_TRANSLATION_STATE" + | "REVIEW" + | "SET_BASE_TRANSLATION" + | "SET_TRANSLATIONS" + | "DISMISS_AUTO_TRANSLATED_STATE" + | "SET_OUTDATED_FLAG" + | "ADD_TRANSLATION_COMMENT" + | "DELETE_TRANSLATION_COMMENT" + | "EDIT_TRANSLATION_COMMENT" + | "SET_TRANSLATION_COMMENT_STATE" + | "DELETE_SCREENSHOT" + | "ADD_SCREENSHOT" + | "EDIT_KEY_TAGS" + | "EDIT_KEY_NAME" + | "DELETE_KEY" + | "CREATE_KEY" + | "IMPORT" + | "CREATE_LANGUAGE" + | "EDIT_LANGUAGE" + | "DELETE_LANGUAGE" + | "CREATE_PROJECT" + | "EDIT_PROJECT" + | "NAMESPACE_EDIT" + | "BATCH_PRE_TRANSLATE_BY_TM" + | "BATCH_MACHINE_TRANSLATE" + | "AUTO_TRANSLATE" + | "BATCH_CLEAR_TRANSLATIONS" + | "BATCH_COPY_TRANSLATIONS" + | "BATCH_SET_TRANSLATION_STATE" + | "CONTENT_DELIVERY_CONFIG_CREATE" + | "CONTENT_DELIVERY_CONFIG_UPDATE" + | "CONTENT_DELIVERY_CONFIG_DELETE" + | "CONTENT_STORAGE_CREATE" + | "CONTENT_STORAGE_UPDATE" + | "CONTENT_STORAGE_DELETE" + | "WEBHOOK_CONFIG_CREATE" + | "WEBHOOK_CONFIG_UPDATE" + | "WEBHOOK_CONFIG_DELETE"; + /** Format: int64 */ + id: number; + /** Format: int64 */ + authorId?: number; + /** Format: int64 */ + projectId?: number; + activityRevisions: components["schemas"]["ActivityRevision"][]; + matchingString?: string; + }; + ActivityModifiedEntity: { + activityRevision: components["schemas"]["ActivityRevision"]; + entityClass: string; + /** Format: int64 */ + entityId: number; + modifications: { + [key: string]: components["schemas"]["PropertyModification"]; + }; + describingData?: { [key: string]: { [key: string]: unknown } }; + describingRelations?: { + [key: string]: components["schemas"]["EntityDescriptionRef"]; + }; + revisionType: "ADD" | "MOD" | "DEL"; + }; + ActivityRevision: { + /** Format: int64 */ + id: number; + /** Format: date-time */ + timestamp: string; + /** Format: int64 */ + authorId?: number; + meta?: { [key: string]: { [key: string]: unknown } }; + type?: + | "UNKNOWN" + | "SET_TRANSLATION_STATE" + | "SET_TRANSLATIONS" + | "DISMISS_AUTO_TRANSLATED_STATE" + | "SET_OUTDATED_FLAG" + | "TRANSLATION_COMMENT_ADD" + | "TRANSLATION_COMMENT_DELETE" + | "TRANSLATION_COMMENT_EDIT" + | "TRANSLATION_COMMENT_SET_STATE" + | "SCREENSHOT_DELETE" + | "SCREENSHOT_ADD" + | "KEY_TAGS_EDIT" + | "KEY_NAME_EDIT" + | "KEY_DELETE" + | "CREATE_KEY" + | "COMPLEX_EDIT" + | "IMPORT" + | "CREATE_LANGUAGE" + | "EDIT_LANGUAGE" + | "DELETE_LANGUAGE" + | "HARD_DELETE_LANGUAGE" + | "CREATE_PROJECT" + | "EDIT_PROJECT" + | "NAMESPACE_EDIT" + | "BATCH_PRE_TRANSLATE_BY_TM" + | "BATCH_MACHINE_TRANSLATE" + | "AUTO_TRANSLATE" + | "BATCH_CLEAR_TRANSLATIONS" + | "BATCH_COPY_TRANSLATIONS" + | "BATCH_SET_TRANSLATION_STATE" + | "BATCH_TAG_KEYS" + | "BATCH_UNTAG_KEYS" + | "BATCH_SET_KEYS_NAMESPACE" + | "AUTOMATION" + | "CONTENT_DELIVERY_CONFIG_CREATE" + | "CONTENT_DELIVERY_CONFIG_UPDATE" + | "CONTENT_DELIVERY_CONFIG_DELETE" + | "CONTENT_STORAGE_CREATE" + | "CONTENT_STORAGE_UPDATE" + | "CONTENT_STORAGE_DELETE" + | "WEBHOOK_CONFIG_CREATE" + | "WEBHOOK_CONFIG_UPDATE" + | "WEBHOOK_CONFIG_DELETE" + | "COMPLEX_TAG_OPERATION"; + /** Format: int64 */ + projectId?: number; + describingRelations: components["schemas"]["ActivityDescribingEntity"][]; + modifiedEntities: components["schemas"]["ActivityModifiedEntity"][]; + batchJobChunkExecution?: components["schemas"]["BatchJobChunkExecution"]; + batchJob?: components["schemas"]["BatchJob"]; + activityGroups: components["schemas"]["ActivityGroup"][]; + /** Format: int64 */ + baseLanguageId?: number; + project?: components["schemas"]["ProjectIdAndBaseLanguageId"]; + isInitializedByInterceptor: boolean; + /** Format: int32 */ + cancelledBatchJobExecutionCount?: number; + }; + ApiKey: { + key?: string; + scopesEnum: ( + | "translations.view" + | "translations.edit" + | "keys.edit" + | "screenshots.upload" + | "screenshots.delete" + | "screenshots.view" + | "activity.view" + | "languages.edit" + | "admin" + | "project.edit" + | "members.view" + | "members.edit" + | "translation-comments.add" + | "translation-comments.edit" + | "translation-comments.set-state" + | "translations.state-edit" + | "keys.view" + | "keys.delete" + | "keys.create" + | "batch-jobs.view" + | "batch-jobs.cancel" + | "translations.batch-by-tm" + | "translations.batch-machine" + | "content-delivery.manage" + | "content-delivery.publish" + | "webhooks.manage" + )[]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + description: string; + keyHash: string; + userAccount: components["schemas"]["UserAccount"]; + project: components["schemas"]["Project"]; + /** Format: date-time */ + expiresAt?: string; + /** Format: date-time */ + lastUsedAt?: string; + encodedKey?: string; + disableActivityLogging: boolean; + }; + AutoTranslationConfig: { + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + project: components["schemas"]["Project"]; + targetLanguage?: components["schemas"]["Language"]; + usingTm: boolean; + usingPrimaryMtService: boolean; + enableForImport: boolean; + disableActivityLogging: boolean; + }; + Automation: { + project: components["schemas"]["Project"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + triggers: components["schemas"]["AutomationTrigger"][]; + actions: components["schemas"]["AutomationAction"][]; + disableActivityLogging: boolean; + }; + AutomationAction: { + automation: components["schemas"]["Automation"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + type: "CONTENT_DELIVERY_PUBLISH" | "WEBHOOK" | "SLACK_SUBSCRIPTION"; + contentDeliveryConfig?: components["schemas"]["ContentDeliveryConfig"]; + webhookConfig?: components["schemas"]["WebhookConfig"]; + slackConfig?: components["schemas"]["SlackConfig"]; + disableActivityLogging: boolean; + }; + AutomationTrigger: { + automation: components["schemas"]["Automation"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + type: "TRANSLATION_DATA_MODIFICATION" | "ACTIVITY"; + activityType?: + | "UNKNOWN" + | "SET_TRANSLATION_STATE" + | "SET_TRANSLATIONS" + | "DISMISS_AUTO_TRANSLATED_STATE" + | "SET_OUTDATED_FLAG" + | "TRANSLATION_COMMENT_ADD" + | "TRANSLATION_COMMENT_DELETE" + | "TRANSLATION_COMMENT_EDIT" + | "TRANSLATION_COMMENT_SET_STATE" + | "SCREENSHOT_DELETE" + | "SCREENSHOT_ADD" + | "KEY_TAGS_EDIT" + | "KEY_NAME_EDIT" + | "KEY_DELETE" + | "CREATE_KEY" + | "COMPLEX_EDIT" + | "IMPORT" + | "CREATE_LANGUAGE" + | "EDIT_LANGUAGE" + | "DELETE_LANGUAGE" + | "HARD_DELETE_LANGUAGE" + | "CREATE_PROJECT" + | "EDIT_PROJECT" + | "NAMESPACE_EDIT" + | "BATCH_PRE_TRANSLATE_BY_TM" + | "BATCH_MACHINE_TRANSLATE" + | "AUTO_TRANSLATE" + | "BATCH_CLEAR_TRANSLATIONS" + | "BATCH_COPY_TRANSLATIONS" + | "BATCH_SET_TRANSLATION_STATE" + | "BATCH_TAG_KEYS" + | "BATCH_UNTAG_KEYS" + | "BATCH_SET_KEYS_NAMESPACE" + | "AUTOMATION" + | "CONTENT_DELIVERY_CONFIG_CREATE" + | "CONTENT_DELIVERY_CONFIG_UPDATE" + | "CONTENT_DELIVERY_CONFIG_DELETE" + | "CONTENT_STORAGE_CREATE" + | "CONTENT_STORAGE_UPDATE" + | "CONTENT_STORAGE_DELETE" + | "WEBHOOK_CONFIG_CREATE" + | "WEBHOOK_CONFIG_UPDATE" + | "WEBHOOK_CONFIG_DELETE" + | "COMPLEX_TAG_OPERATION"; + /** Format: int64 */ + debounceDurationInMs?: number; + disableActivityLogging: boolean; + }; + AzureContentStorageConfig: { + contentStorage: components["schemas"]["ContentStorage"]; + connectionString: string; + containerName: string; + }; + BatchJob: { + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + project: components["schemas"]["Project"]; + author?: components["schemas"]["UserAccount"]; + target: { [key: string]: unknown }[]; + /** Format: int32 */ + totalItems: number; + /** Format: int32 */ + totalChunks: number; + /** Format: int32 */ + chunkSize: number; + status: + | "PENDING" + | "RUNNING" + | "SUCCESS" + | "FAILED" + | "CANCELLED" + | "DEBOUNCED"; + type: + | "PRE_TRANSLATE_BT_TM" + | "MACHINE_TRANSLATE" + | "AUTO_TRANSLATE" + | "DELETE_KEYS" + | "SET_TRANSLATIONS_STATE" + | "CLEAR_TRANSLATIONS" + | "COPY_TRANSLATIONS" + | "TAG_KEYS" + | "UNTAG_KEYS" + | "SET_KEYS_NAMESPACE" + | "AUTOMATION"; + activityRevision?: components["schemas"]["ActivityRevision"]; + params?: { [key: string]: unknown }; + /** Format: int32 */ + maxPerJobConcurrency: number; + jobCharacter: "SLOW" | "FAST"; + hidden: boolean; + /** Format: int64 */ + debounceDurationInMs?: number; + /** Format: int64 */ + debounceMaxWaitTimeInMs?: number; + /** Format: date-time */ + lastDebouncingEvent?: string; + debouncingKey?: string; + dto: components["schemas"]["BatchJobDto"]; + chunkedTarget: { [key: string]: unknown }[][]; + disableActivityLogging: boolean; + }; + BatchJobChunkExecution: { + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + batchJob: components["schemas"]["BatchJob"]; + status: "PENDING" | "RUNNING" | "SUCCESS" | "FAILED" | "CANCELLED"; + /** Format: int32 */ + chunkNumber: number; + successTargets: { [key: string]: unknown }[]; + stackTrace?: string; + errorKey?: string; + errorMessage?: + | "unauthenticated" + | "api_access_forbidden" + | "api_key_not_found" + | "invalid_api_key" + | "invalid_project_api_key" + | "project_api_key_expired" + | "bad_credentials" + | "mfa_enabled" + | "invalid_otp_code" + | "mfa_not_enabled" + | "can_not_revoke_own_permissions" + | "data_corrupted" + | "invitation_code_does_not_exist_or_expired" + | "language_tag_exists" + | "language_name_exists" + | "language_not_found" + | "operation_not_permitted" + | "registrations_not_allowed" + | "project_not_found" + | "resource_not_found" + | "scope_not_found" + | "key_exists" + | "third_party_auth_error_message" + | "third_party_auth_no_email" + | "third_party_auth_no_sub" + | "third_party_auth_unknown_error" + | "email_already_verified" + | "third_party_unauthorized" + | "third_party_google_workspace_mismatch" + | "username_already_exists" + | "username_or_password_invalid" + | "user_already_has_permissions" + | "user_already_has_role" + | "user_not_found" + | "file_not_image" + | "file_too_big" + | "invalid_timestamp" + | "email_not_verified" + | "missing_callback_url" + | "invalid_jwt_token" + | "expired_jwt_token" + | "general_jwt_error" + | "cannot_find_suitable_address_part" + | "address_part_not_unique" + | "user_is_not_member_of_organization" + | "organization_has_no_other_owner" + | "user_has_no_project_access" + | "user_is_organization_owner" + | "cannot_set_your_own_permissions" + | "user_is_organization_member" + | "property_not_mutable" + | "import_language_not_from_project" + | "existing_language_not_selected" + | "conflict_is_not_resolved" + | "language_already_selected" + | "cannot_parse_file" + | "could_not_resolve_property" + | "cannot_add_more_then_100_languages" + | "no_languages_provided" + | "language_with_base_language_tag_not_found" + | "language_not_from_project" + | "namespace_not_from_project" + | "cannot_delete_base_language" + | "key_not_from_project" + | "max_screenshots_exceeded" + | "translation_not_from_project" + | "can_edit_only_own_comment" + | "request_parse_error" + | "filter_by_value_state_not_valid" + | "import_has_expired" + | "tag_not_from_project" + | "translation_text_too_long" + | "invalid_recaptcha_token" + | "cannot_leave_owning_project" + | "cannot_leave_project_with_organization_role" + | "dont_have_direct_permissions" + | "tag_too_log" + | "too_many_uploaded_images" + | "one_or_more_images_not_found" + | "screenshot_not_of_key" + | "service_not_found" + | "too_many_requests" + | "translation_not_found" + | "out_of_credits" + | "key_not_found" + | "organization_not_found" + | "cannot_find_base_language" + | "base_language_not_found" + | "no_exported_result" + | "cannot_set_your_own_role" + | "only_translate_review_or_view_permission_accepts_view_languages" + | "oauth2_token_url_not_set" + | "oauth2_user_url_not_set" + | "email_already_invited_or_member" + | "price_not_found" + | "invoice_not_from_organization" + | "invoice_not_found" + | "plan_not_found" + | "plan_not_available_any_more" + | "no_auto_translation_method" + | "cannot_translate_base_language" + | "pat_not_found" + | "invalid_pat" + | "pat_expired" + | "operation_unavailable_for_account_type" + | "validation_email_is_not_valid" + | "current_password_required" + | "cannot_create_organization" + | "wrong_current_password" + | "wrong_param_type" + | "expired_super_jwt_token" + | "cannot_delete_your_own_account" + | "cannot_sort_by_this_column" + | "namespace_not_found" + | "namespace_exists" + | "invalid_authentication_method" + | "unknown_sort_property" + | "only_review_permission_accepts_state_change_languages" + | "only_translate_or_review_permission_accepts_translate_languages" + | "cannot_set_language_permissions_for_admin_scope" + | "cannot_set_view_languages_without_translations_view_scope" + | "cannot_set_translate_languages_without_translations_edit_scope" + | "cannot_set_state_change_languages_without_translations_state_edit_scope" + | "language_not_permitted" + | "scopes_has_to_be_set" + | "set_exactly_one_of_scopes_or_type" + | "translation_exists" + | "import_keys_error" + | "provide_only_one_of_screenshots_and_screenshot_uploaded_image_ids" + | "multiple_projects_not_supported" + | "plan_translation_limit_exceeded" + | "feature_not_enabled" + | "license_key_not_found" + | "cannot_set_view_languages_without_for_level_based_permissions" + | "cannot_set_different_translate_and_state_change_languages_for_level_based_permissions" + | "cannot_disable_your_own_account" + | "subscription_not_found" + | "invoice_does_not_have_usage" + | "customer_not_found" + | "subscription_not_active" + | "organization_already_subscribed" + | "organization_not_subscribed" + | "license_key_used_by_another_instance" + | "translation_spending_limit_exceeded" + | "credit_spending_limit_exceeded" + | "seats_spending_limit_exceeded" + | "this_instance_is_already_licensed" + | "big_meta_not_from_project" + | "mt_service_not_enabled" + | "project_not_selected" + | "organization_not_selected" + | "plan_has_subscribers" + | "translation_failed" + | "batch_job_not_found" + | "key_exists_in_namespace" + | "tag_is_blank" + | "execution_failed_on_management_error" + | "translation_api_rate_limit" + | "cannot_finalize_activity" + | "formality_not_supported_by_service" + | "language_not_supported_by_service" + | "rate_limited" + | "pat_access_not_allowed" + | "pak_access_not_allowed" + | "cannot_modify_disabled_translation" + | "azure_config_required" + | "s3_config_required" + | "content_storage_config_required" + | "content_storage_test_failed" + | "content_storage_config_invalid" + | "invalid_connection_string" + | "cannot_create_azure_storage_client" + | "s3_access_key_required" + | "azure_connection_string_required" + | "s3_secret_key_required" + | "cannot_store_file_to_content_storage" + | "unexpected_error_while_publishing_to_content_storage" + | "webhook_responded_with_non_200_status" + | "unexpected_error_while_executing_webhook" + | "content_storage_is_in_use" + | "cannot_set_state_for_missing_translation" + | "no_project_id_provided" + | "license_key_not_provided" + | "subscription_already_canceled" + | "user_is_subscribed_to_paid_plan" + | "cannot_create_free_plan_without_fixed_type" + | "cannot_modify_plan_free_status" + | "key_id_not_provided" + | "free_self_hosted_seat_limit_exceeded" + | "advanced_params_not_supported" + | "plural_forms_not_found_for_language" + | "nested_plurals_not_supported" + | "message_is_not_plural" + | "content_outside_plural_forms" + | "invalid_plural_form" + | "multiple_plurals_not_supported" + | "custom_values_json_too_long" + | "unsupported_po_message_format" + | "plural_forms_data_loss" + | "current_user_does_not_own_image" + | "user_cannot_view_this_organization" + | "user_is_not_owner_of_organization" + | "pak_created_for_different_project" + | "custom_slug_is_only_applicable_for_custom_storage" + | "invalid_slug_format" + | "batch_job_cancellation_timeout" + | "import_failed" + | "cannot_add_more_then_1000_languages" + | "no_data_to_import" + | "multiple_namespaces_mapped_to_single_file" + | "multiple_mappings_for_same_file_language_name" + | "multiple_mappings_for_null_file_language_name" + | "too_many_mappings_for_file" + | "missing_placeholder_in_template" + | "tag_not_found" + | "cannot_parse_encrypted_slack_login_data" + | "slack_workspace_not_found" + | "cannot_fetch_user_details_from_slack" + | "slack_missing_scope" + | "slack_not_connected_to_your_account" + | "slack_invalid_command" + | "slack_not_subscribed_yet" + | "slack_connection_failed" + | "tolgee_account_already_connected" + | "slack_not_configured" + | "slack_workspace_already_connected" + | "slack_connection_error" + | "email_verification_code_not_valid"; + /** Format: date-time */ + executeAfter?: string; + retry: boolean; + activityRevision?: components["schemas"]["ActivityRevision"]; + disableActivityLogging: boolean; + }; + BatchJobDto: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + projectId: number; + /** Format: int64 */ + authorId?: number; + target: { [key: string]: unknown }[]; + /** Format: int32 */ + totalItems: number; + /** Format: int32 */ + totalChunks: number; + /** Format: int32 */ + chunkSize: number; + status: + | "PENDING" + | "RUNNING" + | "SUCCESS" + | "FAILED" + | "CANCELLED" + | "DEBOUNCED"; + type: + | "PRE_TRANSLATE_BT_TM" + | "MACHINE_TRANSLATE" + | "AUTO_TRANSLATE" + | "DELETE_KEYS" + | "SET_TRANSLATIONS_STATE" + | "CLEAR_TRANSLATIONS" + | "COPY_TRANSLATIONS" + | "TAG_KEYS" + | "UNTAG_KEYS" + | "SET_KEYS_NAMESPACE" + | "AUTOMATION"; + params?: { [key: string]: unknown }; + /** Format: int32 */ + maxPerJobConcurrency: number; + jobCharacter: "SLOW" | "FAST"; + hidden: boolean; + debouncingKey?: string; + /** Format: int64 */ + createdAt?: number; + /** Format: int64 */ + lastDebouncingEvent?: number; + /** Format: int64 */ + debounceDurationInMs?: number; + /** Format: int64 */ + debounceMaxWaitTimeInMs?: number; + chunkedTarget: { [key: string]: unknown }[][]; + }; + ContentDeliveryConfig: { + project: components["schemas"]["Project"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + name: string; + slug: string; + customSlug: boolean; + contentStorage?: components["schemas"]["ContentStorage"]; + automationActions: components["schemas"]["AutomationAction"][]; + /** Format: date-time */ + lastPublished?: string; + pruneBeforePublish: boolean; + /** + * @description Languages to be contained in export. + * + * If null, all languages are exported + * @example en + */ + languages?: string[]; + /** @description Format to export to */ + format: + | "JSON" + | "JSON_TOLGEE" + | "XLIFF" + | "PO" + | "APPLE_STRINGS_STRINGSDICT" + | "APPLE_XLIFF" + | "ANDROID_XML" + | "FLUTTER_ARB" + | "PROPERTIES" + | "YAML_RUBY" + | "YAML"; + /** + * @description Delimiter to structure file content. + * + * e.g. For key "home.header.title" would result in {"home": {"header": "title": {"Hello"}}} structure. + * + * When null, resulting file won't be structured. Works only for generic structured formats (e.g. JSON, YAML), + * specific formats like `YAML_RUBY` don't honor this parameter. + */ + structureDelimiter?: string; + /** + * @description If true, for structured formats (like JSON) arrays are supported. + * + * e.g. Key hello[0] will be exported as {"hello": ["..."]} + */ + supportArrays: boolean; + /** @description Filter key IDs to be contained in export */ + filterKeyId?: number[]; + /** @description Filter key IDs not to be contained in export */ + filterKeyIdNot?: number[]; + /** + * @description Filter keys tagged by. + * + * This filter works the same as `filterTagIn` but in this cases it accepts single tag only. + */ + filterTag?: string; + /** @description Filter keys tagged by one of provided tags */ + filterTagIn?: string[]; + /** @description Filter keys not tagged by one of provided tags */ + filterTagNotIn?: string[]; + /** @description Filter keys with prefix */ + filterKeyPrefix?: string; + /** @description Filter translations with state. By default, all states except untranslated is exported. */ + filterState?: ("UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED")[]; + /** @description Filter translations with namespace. By default, all namespaces everything are exported. To export default namespace, use empty string. */ + filterNamespace?: string[]; + /** + * @description Message format to be used for export. + * + * e.g. PHP_PO: Hello %s, ICU: Hello {name}. + * + * This property is honored only for generic formats like JSON or YAML. + * For specific formats like `YAML_RUBY` it's ignored. + */ + messageFormat?: + | "C_SPRINTF" + | "PHP_SPRINTF" + | "JAVA_STRING_FORMAT" + | "APPLE_SPRINTF" + | "RUBY_SPRINTF" + | "ICU"; + /** + * @description This is a template that defines the structure of the resulting .zip file content. + * + * The template is a string that can contain the following placeholders: {namespace}, {languageTag}, + * {androidLanguageTag}, {snakeLanguageTag}, {extension}. + * + * For example, when exporting to JSON with the template `{namespace}/{languageTag}.{extension}`, + * the English translations of the `home` namespace will be stored in `home/en.json`. + * + * The `{snakeLanguageTag}` placeholder is the same as `{languageTag}` but in snake case. (e.g., en_US). + * + * The Android specific `{androidLanguageTag}` placeholder is the same as `{languageTag}` + * but in Android format. (e.g., en-rUS) + */ + fileStructureTemplate?: string; + disableActivityLogging: boolean; + }; + ContentStorage: { + project: components["schemas"]["Project"]; + name: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + publicUrlPrefix?: string; + azureContentStorageConfig?: components["schemas"]["AzureContentStorageConfig"]; + s3ContentStorageConfig?: components["schemas"]["S3ContentStorageConfig"]; + configs: components["schemas"]["StorageConfig"][]; + storageConfig?: components["schemas"]["StorageConfig"]; + disableActivityLogging: boolean; + }; + EmailVerification: { + /** Format: int64 */ + id?: number; + code: string; + newEmail?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + userAccount: components["schemas"]["UserAccount"]; + }; + EntityDescriptionRef: { + entityClass: string; + /** Format: int64 */ + entityId: number; + }; + Import: { + project: components["schemas"]["Project"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + author: components["schemas"]["UserAccount"]; + files: components["schemas"]["ImportFile"][]; + /** Format: date-time */ + deletedAt?: string; + disableActivityLogging: boolean; + }; + ImportFile: { + name?: string; + import: components["schemas"]["Import"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + issues: components["schemas"]["ImportFileIssue"][]; + keys: components["schemas"]["ImportKey"][]; + languages: components["schemas"]["ImportLanguage"][]; + namespace?: string; + needsParamConversion: boolean; + disableActivityLogging: boolean; + }; + ImportFileIssue: { + file: components["schemas"]["ImportFile"]; + type: + | "KEY_IS_NOT_STRING" + | "MULTIPLE_VALUES_FOR_KEY_AND_LANGUAGE" + | "VALUE_IS_NOT_STRING" + | "KEY_IS_EMPTY" + | "VALUE_IS_EMPTY" + | "PO_MSGCTXT_NOT_SUPPORTED" + | "ID_ATTRIBUTE_NOT_PROVIDED" + | "TARGET_NOT_PROVIDED" + | "TRANSLATION_TOO_LONG" + | "KEY_IS_BLANK" + | "TRANSLATION_DEFINED_IN_ANOTHER_FILE" + | "INVALID_CUSTOM_VALUES"; + params: components["schemas"]["ImportFileIssueParam"][]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + disableActivityLogging: boolean; + }; + ImportFileIssueParam: { + issue: components["schemas"]["ImportFileIssue"]; + type: + | "KEY_NAME" + | "KEY_ID" + | "LANGUAGE_ID" + | "KEY_INDEX" + | "VALUE" + | "LINE" + | "FILE_NODE_ORIGINAL" + | "LANGUAGE_NAME"; + value: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + disableActivityLogging: boolean; + }; + ImportKey: { + name: string; + file: components["schemas"]["ImportFile"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + translations: components["schemas"]["ImportTranslation"][]; + keyMeta?: components["schemas"]["KeyMeta"]; + pluralArgName?: string; + disableActivityLogging: boolean; + }; + ImportLanguage: { + name: string; + file: components["schemas"]["ImportFile"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + translations: components["schemas"]["ImportTranslation"][]; + existingLanguage?: components["schemas"]["Language"]; + disableActivityLogging: boolean; + }; + ImportTranslation: { + text?: string; + language: components["schemas"]["ImportLanguage"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + key: components["schemas"]["ImportKey"]; + conflict?: components["schemas"]["Translation"]; + override: boolean; + resolvedHash?: string; + isSelectedToImport: boolean; + isPlural: boolean; + rawData?: { [key: string]: unknown }; + convertor?: + | "JSON_ICU" + | "JSON_JAVA" + | "JSON_PHP" + | "JSON_RUBY" + | "JSON_C" + | "PO_PHP" + | "PO_C" + | "PO_JAVA" + | "PO_ICU" + | "PO_RUBY" + | "STRINGS" + | "STRINGSDICT" + | "APPLE_XLIFF" + | "PROPERTIES_ICU" + | "PROPERTIES_JAVA" + | "PROPERTIES_UNKNOWN" + | "ANDROID_XML" + | "FLUTTER_ARB" + | "YAML_RUBY" + | "YAML_JAVA" + | "YAML_ICU" + | "YAML_PHP" + | "YAML_UNKNOWN" + | "XLIFF_ICU" + | "XLIFF_JAVA" + | "XLIFF_PHP" + | "XLIFF_RUBY"; + resolved: boolean; + disableActivityLogging: boolean; + }; + Invitation: { + /** Format: int64 */ + id?: number; + code: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + permission?: components["schemas"]["Permission"]; + organizationRole?: components["schemas"]["OrganizationRole"]; + name?: string; + email?: string; + }; + Key: { + name: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + project: components["schemas"]["Project"]; + namespace?: components["schemas"]["Namespace"]; + translations: components["schemas"]["Translation"][]; + keyMeta?: components["schemas"]["KeyMeta"]; + keyScreenshotReferences: components["schemas"]["KeyScreenshotReference"][]; + isPlural: boolean; + pluralArgName?: string; + disableActivityLogging: boolean; + }; + KeyCodeReference: { + keyMeta: components["schemas"]["KeyMeta"]; + author: components["schemas"]["UserAccount"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + path: string; + /** Format: int64 */ + line?: number; + fromImport: boolean; + disableActivityLogging: boolean; + }; + KeyComment: { + keyMeta: components["schemas"]["KeyMeta"]; + author: components["schemas"]["UserAccount"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + fromImport: boolean; + text: string; + disableActivityLogging: boolean; + }; + KeyMeta: { + key?: components["schemas"]["Key"]; + importKey?: components["schemas"]["ImportKey"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + comments: components["schemas"]["KeyComment"][]; + codeReferences: components["schemas"]["KeyCodeReference"][]; + tags: components["schemas"]["Tag"][]; + description?: string; + custom?: { [key: string]: { [key: string]: unknown } }; + disableActivityLogging: boolean; + }; + KeyScreenshotReference: { + key: components["schemas"]["Key"]; + screenshot: components["schemas"]["Screenshot"]; + positions?: components["schemas"]["KeyInScreenshotPosition"][]; + originalText?: string; + }; + Language: { + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + translations: components["schemas"]["Translation"][]; + project: components["schemas"]["Project"]; + tag: string; + name: string; + originalName?: string; + flagEmoji?: string; + mtServiceConfig?: components["schemas"]["MtServiceConfig"]; + autoTranslationConfig?: components["schemas"]["AutoTranslationConfig"]; + stats?: components["schemas"]["LanguageStats"]; + aiTranslatorPromptDescription?: string; + /** Format: date-time */ + deletedAt?: string; + disableActivityLogging: boolean; + }; + LanguageStats: { + language: components["schemas"]["Language"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + /** Format: int64 */ + untranslatedWords: number; + /** Format: int64 */ + translatedWords: number; + /** Format: int64 */ + reviewedWords: number; + /** Format: int64 */ + untranslatedKeys: number; + /** Format: int64 */ + translatedKeys: number; + /** Format: int64 */ + reviewedKeys: number; + /** Format: double */ + untranslatedPercentage: number; + /** Format: double */ + translatedPercentage: number; + /** Format: double */ + reviewedPercentage: number; + /** Format: int64 */ + languageId: number; + disableActivityLogging: boolean; + }; + MtCreditBucket: { + userAccount?: components["schemas"]["UserAccount"]; + organization?: components["schemas"]["Organization"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + /** Format: int64 */ + credits: number; + /** Format: int64 */ + extraCredits: number; + /** Format: int64 */ + bucketSize: number; + /** Format: date-time */ + refilled: string; + disableActivityLogging: boolean; + }; + MtServiceConfig: { + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + project: components["schemas"]["Project"]; + targetLanguage?: components["schemas"]["Language"]; + primaryService?: + | "GOOGLE" + | "AWS" + | "DEEPL" + | "AZURE" + | "BAIDU" + | "TOLGEE"; + primaryServiceFormality?: "FORMAL" | "INFORMAL" | "DEFAULT"; + enabledServices: ( + | "GOOGLE" + | "AWS" + | "DEEPL" + | "AZURE" + | "BAIDU" + | "TOLGEE" + )[]; + awsFormality: "FORMAL" | "INFORMAL" | "DEFAULT"; + deeplFormality: "FORMAL" | "INFORMAL" | "DEFAULT"; + tolgeeFormality: "FORMAL" | "INFORMAL" | "DEFAULT"; + primaryServiceInfo?: components["schemas"]["MtServiceInfo"]; + enabledServicesInfo: components["schemas"]["MtServiceInfo"][]; + disableActivityLogging: boolean; + }; + Namespace: { + name: string; + project: components["schemas"]["Project"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + disableActivityLogging: boolean; + }; + NotificationPreferences: { + userAccount: components["schemas"]["UserAccount"]; + project?: components["schemas"]["Project"]; + disabledNotifications: ( + | "ACTIVITY_LANGUAGES_CREATED" + | "ACTIVITY_KEYS_CREATED" + | "ACTIVITY_KEYS_UPDATED" + | "ACTIVITY_KEYS_SCREENSHOTS_UPLOADED" + | "ACTIVITY_SOURCE_STRINGS_UPDATED" + | "ACTIVITY_TRANSLATIONS_UPDATED" + | "ACTIVITY_TRANSLATION_OUTDATED" + | "ACTIVITY_TRANSLATION_REVIEWED" + | "ACTIVITY_TRANSLATION_UNREVIEWED" + | "ACTIVITY_NEW_COMMENTS" + | "ACTIVITY_COMMENTS_MENTION" + | "BATCH_JOB_ERRORED" + )[]; + /** Format: int64 */ + id: number; + }; + Organization: { + /** Format: int64 */ + id: number; + name: string; + description?: string; + slug: string; + mtCreditBucket?: components["schemas"]["MtCreditBucket"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + basePermission: components["schemas"]["Permission"]; + projects: components["schemas"]["Project"][]; + preferredBy: components["schemas"]["UserPreferences"][]; + avatarHash?: string; + /** Format: date-time */ + deletedAt?: string; + organizationSlackWorkspace: components["schemas"]["OrganizationSlackWorkspace"][]; + }; + OrganizationRole: { + invitation?: components["schemas"]["Invitation"]; + type: "MEMBER" | "OWNER"; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + user?: components["schemas"]["UserAccount"]; + organization: components["schemas"]["Organization"]; + disableActivityLogging: boolean; + }; + OrganizationSlackWorkspace: { + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + organization: components["schemas"]["Organization"]; + author: components["schemas"]["UserAccount"]; + slackTeamId: string; + slackTeamName: string; + accessToken: string; + slackSubscriptions: components["schemas"]["SlackConfig"][]; + disableActivityLogging: boolean; + }; + Pat: { + tokenHash: string; + description: string; + /** Format: date-time */ + expiresAt?: string; + /** Format: date-time */ + lastUsedAt?: string; + token?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + userAccount: components["schemas"]["UserAccount"]; + disableActivityLogging: boolean; + }; + Permission: { + /** Format: int64 */ + id: number; + user?: components["schemas"]["UserAccount"]; + organization?: components["schemas"]["Organization"]; + invitation?: components["schemas"]["Invitation"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + _scopes?: ( + | "translations.view" + | "translations.edit" + | "keys.edit" + | "screenshots.upload" + | "screenshots.delete" + | "screenshots.view" + | "activity.view" + | "languages.edit" + | "admin" + | "project.edit" + | "members.view" + | "members.edit" + | "translation-comments.add" + | "translation-comments.edit" + | "translation-comments.set-state" + | "translations.state-edit" + | "keys.view" + | "keys.delete" + | "keys.create" + | "batch-jobs.view" + | "batch-jobs.cancel" + | "translations.batch-by-tm" + | "translations.batch-machine" + | "content-delivery.manage" + | "content-delivery.publish" + | "webhooks.manage" + )[]; + type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; + translateLanguages: components["schemas"]["Language"][]; + viewLanguages: components["schemas"]["Language"][]; + stateChangeLanguages: components["schemas"]["Language"][]; + project?: components["schemas"]["Project"]; + scopes: ( + | "translations.view" + | "translations.edit" + | "keys.edit" + | "screenshots.upload" + | "screenshots.delete" + | "screenshots.view" + | "activity.view" + | "languages.edit" + | "admin" + | "project.edit" + | "members.view" + | "members.edit" + | "translation-comments.add" + | "translation-comments.edit" + | "translation-comments.set-state" + | "translations.state-edit" + | "keys.view" + | "keys.delete" + | "keys.create" + | "batch-jobs.view" + | "batch-jobs.cancel" + | "translations.batch-by-tm" + | "translations.batch-machine" + | "content-delivery.manage" + | "content-delivery.publish" + | "webhooks.manage" + )[]; + /** Format: int64 */ + projectId?: number; + granular: boolean; + /** Format: int64 */ + userId?: number; + /** Format: int64 */ + invitationId?: number; + viewLanguageIds?: number[]; + /** Format: int64 */ + organizationId?: number; + translateLanguageIds?: number[]; + stateChangeLanguageIds?: number[]; + }; + Project: { + /** Format: int64 */ + id: number; + name: string; + description?: string; + aiTranslatorPromptDescription?: string; + slug?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + languages: components["schemas"]["Language"][]; + permissions: components["schemas"]["Permission"][]; + keys: components["schemas"]["Key"][]; + apiKeys: components["schemas"]["ApiKey"][]; + userOwner?: components["schemas"]["UserAccount"]; + organizationOwner: components["schemas"]["Organization"]; + baseLanguage?: components["schemas"]["Language"]; + autoTranslationConfigs: components["schemas"]["AutoTranslationConfig"][]; + mtServiceConfig: components["schemas"]["MtServiceConfig"][]; + namespaces: components["schemas"]["Namespace"][]; + defaultNamespace?: components["schemas"]["Namespace"]; + avatarHash?: string; + automations: components["schemas"]["Automation"][]; + contentDeliveryConfigs: components["schemas"]["ContentDeliveryConfig"][]; + contentStorages: components["schemas"]["ContentStorage"][]; + webhookConfigs: components["schemas"]["WebhookConfig"][]; + slackConfigs: components["schemas"]["SlackConfig"][]; + /** @description Whether to disable ICU placeholder visualization in the editor and it's support. */ + icuPlaceholders: boolean; + /** Format: date-time */ + deletedAt?: string; + /** Format: int64 */ + baseLanguageId?: number; + disableActivityLogging: boolean; + }; + ProjectIdAndBaseLanguageId: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + baseLanguageId?: number; + }; + S3ContentStorageConfig: { + contentStorage: components["schemas"]["ContentStorage"]; + bucketName: string; + accessKey: string; + secretKey: string; + endpoint: string; + signingRegion: string; + enabled?: boolean; + contentStorageType?: "S3" | "AZURE"; + }; + SavedSlackMessage: { + messageTimestamp: string; + slackConfig: components["schemas"]["SlackConfig"]; + /** Format: int64 */ + keyId: number; + languageTags: string[]; + createdKeyBlocks: boolean; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + info: components["schemas"]["SlackMessageInfo"][]; + disableActivityLogging: boolean; + }; + Screenshot: { + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + keyScreenshotReferences: components["schemas"]["KeyScreenshotReference"][]; + path: string; + extension?: string; + hasThumbnail: boolean; + location?: string; + /** Format: int32 */ + width: number; + /** Format: int32 */ + height: number; + hash: string; + filename: string; + pathWithSlash: string; + thumbnailFilename: string; + disableActivityLogging: boolean; + }; + SlackConfig: { + project: components["schemas"]["Project"]; + userAccount: components["schemas"]["UserAccount"]; + channelId: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + automationActions: components["schemas"]["AutomationAction"][]; + languageTags: string[]; + events: ("ALL" | "NEW_KEY" | "BASE_CHANGED" | "TRANSLATION_CHANGED")[]; + savedSlackMessage: components["schemas"]["SavedSlackMessage"][]; + isGlobalSubscription: boolean; + preferences: components["schemas"]["SlackConfigPreference"][]; + organizationSlackWorkspace?: components["schemas"]["OrganizationSlackWorkspace"]; + disableActivityLogging: boolean; + }; + SlackConfigPreference: { + slackConfig: components["schemas"]["SlackConfig"]; + languageTag?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + events: ("ALL" | "NEW_KEY" | "BASE_CHANGED" | "TRANSLATION_CHANGED")[]; + disableActivityLogging: boolean; + }; + SlackMessageInfo: { + slackMessage: components["schemas"]["SavedSlackMessage"]; + languageTag: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + subscriptionType: "GLOBAL"; + authorContext: string; + disableActivityLogging: boolean; + }; + SlackUserConnection: { + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + userAccount: components["schemas"]["UserAccount"]; + slackUserId: string; + slackTeamId: string; + disableActivityLogging: boolean; + }; + StorageConfig: { + enabled: boolean; + contentStorageType: "S3" | "AZURE"; + }; + Tag: { + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + name: string; + project: components["schemas"]["Project"]; + keyMetas: components["schemas"]["KeyMeta"][]; + disableActivityLogging: boolean; + }; + Translation: { + text?: { + old?: string; + new?: string; + }; + state?: { + old?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + new?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + }; + auto?: { + old?: boolean; + new?: boolean; + }; + mtProvider?: { + old?: "GOOGLE" | "AWS" | "DEEPL" | "AZURE" | "BAIDU" | "TOLGEE"; + new?: "GOOGLE" | "AWS" | "DEEPL" | "AZURE" | "BAIDU" | "TOLGEE"; + }; + outdated?: { + old?: boolean; + new?: boolean; + }; + }; + UserAccount: { + /** Format: int64 */ + id: number; + username: string; + password?: string; + name: string; + role?: "USER" | "ADMIN"; + accountType?: "LOCAL" | "MANAGED" | "THIRD_PARTY"; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: byte */ + totpKey?: string; + mfaRecoveryCodes: string[]; + /** Format: date-time */ + tokensValidNotBefore?: string; + permissions: components["schemas"]["Permission"][]; + emailVerification?: components["schemas"]["EmailVerification"]; + thirdPartyAuthType?: string; + thirdPartyAuthId?: string; + resetPasswordCode?: string; + organizationRoles: components["schemas"]["OrganizationRole"][]; + preferences?: components["schemas"]["UserPreferences"]; + pats?: components["schemas"]["Pat"][]; + apiKeys?: components["schemas"]["ApiKey"][]; + avatarHash?: string; + /** Format: date-time */ + deletedAt?: string; + /** Format: date-time */ + disabledAt?: string; + isInitialUser: boolean; + passwordChanged: boolean; + isDemo: boolean; + slackUserConnection: components["schemas"]["SlackUserConnection"][]; + slackConfig: components["schemas"]["SlackConfig"][]; + userNotifications: components["schemas"]["UserNotification"][]; + projectNotificationPreferences: components["schemas"]["NotificationPreferences"][]; + mfaEnabled?: boolean; + deletable?: boolean; + deleted: boolean; + needsSuperJwt?: boolean; + globalNotificationPreferences?: components["schemas"]["NotificationPreferences"]; + }; + UserNotification: { + type: + | "ACTIVITY_LANGUAGES_CREATED" + | "ACTIVITY_KEYS_CREATED" + | "ACTIVITY_KEYS_UPDATED" + | "ACTIVITY_KEYS_SCREENSHOTS_UPLOADED" + | "ACTIVITY_SOURCE_STRINGS_UPDATED" + | "ACTIVITY_TRANSLATIONS_UPDATED" + | "ACTIVITY_TRANSLATION_OUTDATED" + | "ACTIVITY_TRANSLATION_REVIEWED" + | "ACTIVITY_TRANSLATION_UNREVIEWED" + | "ACTIVITY_NEW_COMMENTS" + | "ACTIVITY_COMMENTS_MENTION" + | "BATCH_JOB_ERRORED"; + recipient: components["schemas"]["UserAccount"]; + project?: components["schemas"]["Project"]; + modifiedEntities: components["schemas"]["ActivityModifiedEntity"][]; + batchJob?: components["schemas"]["BatchJob"]; + /** Format: int64 */ + id: number; + unread: boolean; + /** Format: date-time */ + markedDoneAt?: string; + /** Format: date-time */ + lastUpdated: string; + }; + UserPreferences: { + userAccount: components["schemas"]["UserAccount"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + language?: string; + preferredOrganization?: components["schemas"]["Organization"]; + /** Format: int64 */ + id: number; + }; + WebhookConfig: { + project: components["schemas"]["Project"]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: int64 */ + id: number; + url: string; + webhookSecret: string; + automationActions: components["schemas"]["AutomationAction"][]; + /** Format: date-time */ + firstFailed?: string; + /** Format: date-time */ + lastExecuted?: string; + disableActivityLogging: boolean; + }; + EntityDescription: { + entityClass: string; + /** Format: int64 */ + entityId: number; + data: { + name?: string; + }; + }; + ProjectActivityUnknownModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "UNKNOWN"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivitySetTranslationStateModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "SET_TRANSLATION_STATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: { + Translation?: { + entityClass?: string; + /** Format: int64 */ + entityId?: number; + description?: { [key: string]: { [key: string]: unknown } }; + modifications?: { + text?: { + old?: string; + new?: string; + }; + state?: { + old?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + new?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + }; + auto?: { + old?: boolean; + new?: boolean; + }; + mtProvider?: { + old?: "GOOGLE" | "AWS" | "DEEPL" | "AZURE" | "BAIDU" | "TOLGEE"; + new?: "GOOGLE" | "AWS" | "DEEPL" | "AZURE" | "BAIDU" | "TOLGEE"; + }; + outdated?: { + old?: boolean; + new?: boolean; + }; + }; + relations?: { + [key: string]: components["schemas"]["ExistenceEntityDescription"]; + }; + exists?: boolean; + }; + }; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivitySetTranslationsModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "SET_TRANSLATIONS"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: { + Translation?: { + entityClass?: string; + /** Format: int64 */ + entityId?: number; + description?: { [key: string]: { [key: string]: unknown } }; + modifications?: { + text?: { + old?: string; + new?: string; + }; + state?: { + old?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + new?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + }; + auto?: { + old?: boolean; + new?: boolean; + }; + mtProvider?: { + old?: "GOOGLE" | "AWS" | "DEEPL" | "AZURE" | "BAIDU" | "TOLGEE"; + new?: "GOOGLE" | "AWS" | "DEEPL" | "AZURE" | "BAIDU" | "TOLGEE"; + }; + outdated?: { + old?: boolean; + new?: boolean; + }; + }; + relations?: { + [key: string]: components["schemas"]["ExistenceEntityDescription"]; + }; + exists?: boolean; + }; + }; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityDismissAutoTranslatedStateModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "DISMISS_AUTO_TRANSLATED_STATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: { + Translation?: { + entityClass?: string; + /** Format: int64 */ + entityId?: number; + description?: { [key: string]: { [key: string]: unknown } }; + modifications?: { + text?: { + old?: string; + new?: string; + }; + state?: { + old?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + new?: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED"; + }; + auto?: { + old?: boolean; + new?: boolean; + }; + mtProvider?: { + old?: "GOOGLE" | "AWS" | "DEEPL" | "AZURE" | "BAIDU" | "TOLGEE"; + new?: "GOOGLE" | "AWS" | "DEEPL" | "AZURE" | "BAIDU" | "TOLGEE"; + }; + outdated?: { + old?: boolean; + new?: boolean; + }; + }; + relations?: { + [key: string]: components["schemas"]["ExistenceEntityDescription"]; + }; + exists?: boolean; + }; + }; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivitySetOutdatedFlagModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "SET_OUTDATED_FLAG"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityTranslationCommentAddModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "TRANSLATION_COMMENT_ADD"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityTranslationCommentDeleteModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "TRANSLATION_COMMENT_DELETE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityTranslationCommentEditModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "TRANSLATION_COMMENT_EDIT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityTranslationCommentSetStateModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "TRANSLATION_COMMENT_SET_STATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityScreenshotDeleteModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "SCREENSHOT_DELETE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityScreenshotAddModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "SCREENSHOT_ADD"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityKeyTagsEditModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "KEY_TAGS_EDIT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityKeyNameEditModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "KEY_NAME_EDIT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityKeyDeleteModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "KEY_DELETE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityCreateKeyModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CREATE_KEY"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: { + Key?: { + entityClass?: string; + /** Format: int64 */ + entityId?: number; + description?: { [key: string]: { [key: string]: unknown } }; + modifications?: { + name?: { + old?: unknown; + new?: string; + }; + isPlural?: { + old?: unknown; + new?: boolean; + }; + pluralArgName?: { + old?: unknown; + new?: string; + }; + namespace?: { + old?: unknown; + new?: { + entityClass: string; + /** Format: int64 */ + entityId: number; + data: { + name?: string; + }; + }; + }; + }; + relations?: { + [key: string]: components["schemas"]["ExistenceEntityDescription"]; + }; + exists?: boolean; + }; + KeyMeta?: { + entityClass?: string; + /** Format: int64 */ + entityId?: number; + description?: { [key: string]: { [key: string]: unknown } }; + modifications?: { + description?: { + old?: unknown; + new?: string; + }; + custom?: { + old?: unknown; + new?: { [key: string]: { [key: string]: unknown } }; + }; + tags?: { + old?: unknown; + new?: { + entityClass: string; + /** Format: int64 */ + entityId: number; + data: { [key: string]: unknown }; + }; + }; + }; + relations?: { + [key: string]: components["schemas"]["ExistenceEntityDescription"]; + }; + exists?: boolean; + }; + }; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityComplexEditModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "COMPLEX_EDIT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityImportModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "IMPORT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityCreateLanguageModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CREATE_LANGUAGE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityEditLanguageModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_LANGUAGE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityDeleteLanguageModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "DELETE_LANGUAGE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityHardDeleteLanguageModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "HARD_DELETE_LANGUAGE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityCreateProjectModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CREATE_PROJECT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityEditProjectModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_PROJECT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityNamespaceEditModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "NAMESPACE_EDIT"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityBatchPreTranslateByTmModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_PRE_TRANSLATE_BY_TM"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityBatchMachineTranslateModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_MACHINE_TRANSLATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityAutoTranslateModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "AUTO_TRANSLATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityBatchClearTranslationsModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_CLEAR_TRANSLATIONS"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityBatchCopyTranslationsModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_COPY_TRANSLATIONS"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityBatchSetTranslationStateModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_SET_TRANSLATION_STATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityBatchTagKeysModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_TAG_KEYS"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityBatchUntagKeysModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_UNTAG_KEYS"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityBatchSetKeysNamespaceModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_SET_KEYS_NAMESPACE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityAutomationModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "AUTOMATION"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityContentDeliveryConfigCreateModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_DELIVERY_CONFIG_CREATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityContentDeliveryConfigUpdateModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_DELIVERY_CONFIG_UPDATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityContentDeliveryConfigDeleteModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_DELIVERY_CONFIG_DELETE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityContentStorageCreateModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_STORAGE_CREATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityContentStorageUpdateModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_STORAGE_UPDATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityContentStorageDeleteModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_STORAGE_DELETE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityWebhookConfigCreateModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "WEBHOOK_CONFIG_CREATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityWebhookConfigUpdateModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "WEBHOOK_CONFIG_UPDATE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityWebhookConfigDeleteModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "WEBHOOK_CONFIG_DELETE"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + ProjectActivityComplexTagOperationModel: { + /** Format: int64 */ + revisionId: number; + /** Format: int64 */ + timestamp: number; + type: "COMPLEX_TAG_OPERATION"; + author?: components["schemas"]["ProjectActivityAuthorModel"]; + modifiedEntities?: unknown; + meta?: { [key: string]: { [key: string]: unknown } }; + counts?: { [key: string]: number }; + params?: { [key: string]: unknown }; + }; + SetTranslationsGroupModel: { + /** Format: int32 */ + translationCount: number; + }; + CreateKeyGroupModel: { + /** Format: int32 */ + keyCount: number; + }; + ActivityGroupLanguageModel: { + /** Format: int64 */ + id: number; + name: string; + originalName: string; + tag: string; + flagEmoji: string; + }; + CreateProjectGroupModel: { + /** Format: int64 */ + id: number; + name: string; + languages: components["schemas"]["ActivityGroupLanguageModel"][]; + description?: string; + }; + ActivityGroupSetTranslationStateModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "SET_TRANSLATION_STATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupReviewModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "REVIEW"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupSetBaseTranslationModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "SET_BASE_TRANSLATION"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupSetTranslationsModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "SET_TRANSLATIONS"; + author?: components["schemas"]["SimpleUserAccountModel"]; + data?: { + /** Format: int32 */ + translationCount: number; + }; + mentionedLanguageIds: number[]; + }; + ActivityGroupDismissAutoTranslatedStateModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "DISMISS_AUTO_TRANSLATED_STATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupSetOutdatedFlagModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "SET_OUTDATED_FLAG"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupAddTranslationCommentModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "ADD_TRANSLATION_COMMENT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupDeleteTranslationCommentModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "DELETE_TRANSLATION_COMMENT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupEditTranslationCommentModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_TRANSLATION_COMMENT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupSetTranslationCommentStateModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "SET_TRANSLATION_COMMENT_STATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupDeleteScreenshotModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "DELETE_SCREENSHOT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupAddScreenshotModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "ADD_SCREENSHOT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupEditKeyTagsModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_KEY_TAGS"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupEditKeyNameModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_KEY_NAME"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupDeleteKeyModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "DELETE_KEY"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupCreateKeyModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CREATE_KEY"; + author?: components["schemas"]["SimpleUserAccountModel"]; + data?: { + /** Format: int32 */ + keyCount: number; + }; + mentionedLanguageIds: number[]; + }; + ActivityGroupImportModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "IMPORT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupCreateLanguageModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CREATE_LANGUAGE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupEditLanguageModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_LANGUAGE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupDeleteLanguageModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "DELETE_LANGUAGE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupCreateProjectModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CREATE_PROJECT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + data?: { + /** Format: int64 */ + id: number; + name: string; + languages: components["schemas"]["ActivityGroupLanguageModel"][]; + description?: string; + }; + mentionedLanguageIds: number[]; + }; + ActivityGroupEditProjectModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "EDIT_PROJECT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupNamespaceEditModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "NAMESPACE_EDIT"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupBatchPreTranslateByTmModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_PRE_TRANSLATE_BY_TM"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupBatchMachineTranslateModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_MACHINE_TRANSLATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupAutoTranslateModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "AUTO_TRANSLATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupBatchClearTranslationsModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_CLEAR_TRANSLATIONS"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupBatchCopyTranslationsModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_COPY_TRANSLATIONS"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupBatchSetTranslationStateModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "BATCH_SET_TRANSLATION_STATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupContentDeliveryConfigCreateModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_DELIVERY_CONFIG_CREATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupContentDeliveryConfigUpdateModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_DELIVERY_CONFIG_UPDATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupContentDeliveryConfigDeleteModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_DELIVERY_CONFIG_DELETE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupContentStorageCreateModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_STORAGE_CREATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupContentStorageUpdateModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_STORAGE_UPDATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupContentStorageDeleteModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "CONTENT_STORAGE_DELETE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupWebhookConfigCreateModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "WEBHOOK_CONFIG_CREATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupWebhookConfigUpdateModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "WEBHOOK_CONFIG_UPDATE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + ActivityGroupWebhookConfigDeleteModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + timestamp: number; + type: "WEBHOOK_CONFIG_DELETE"; + author?: components["schemas"]["SimpleUserAccountModel"]; + mentionedLanguageIds: number[]; + }; + }; +} + +export interface operations { + /** Returns information about currently authenticated user. */ + getInfo_2: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PrivateUserAccountModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Updates current user's profile information. */ + updateUser: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PrivateUserAccountModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserUpdateRequestDto"]; + }; + }; + }; + updateUserOld: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PrivateUserAccountModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserUpdateRequestDto"]; + }; + }; + }; + delete: { + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Updates current user's password. Invalidates all previous sessions upon success. */ + updateUserPassword: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["JwtAuthenticationResponse"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserUpdatePasswordRequestDto"]; + }; + }; + }; + /** Enables TOTP-based two-factor authentication. Invalidates all previous sessions upon success. */ + enableMfa: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["JwtAuthenticationResponse"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserTotpEnableRequestDto"]; + }; + }; + }; + /** Disables TOTP-based two-factor authentication. Invalidates all previous sessions upon success. */ + disableMfa: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["JwtAuthenticationResponse"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserTotpDisableRequestDto"]; + }; + }; + }; + /** Regenerates multi-factor authentication recovery codes */ + regenerateRecoveryCodes: { + responses: { + /** OK */ + 200: { + content: { + "application/json": string[]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ 404: { content: { "application/json": @@ -4392,14 +8544,250 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["UserMfaRecoveryRequestDto"]; + }; + }; }; - /** Updates current user's profile information. */ - updateUser: { + uploadAvatar: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PrivateUserAccountModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + avatar: string; + }; + }; + }; + }; + removeAvatar: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PrivateUserAccountModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + setPreferredOrganization: { + parameters: { + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + setLanguage: { + parameters: { + path: { + languageTag: string; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Marks guide step as completed */ + completeGuideStep: { + parameters: { + path: { + step: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["QuickStartModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Sets open state of the quick start guide */ + setOpenState: { + parameters: { + path: { + open: boolean; + }; + }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["PrivateUserAccountModel"]; + "application/json": components["schemas"]["QuickStartModel"]; }; }; /** Bad Request */ @@ -4435,18 +8823,19 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["UserUpdateRequestDto"]; + }; + /** Sets finished state of the quick start guide */ + setFinishedState: { + parameters: { + path: { + finished: boolean; }; }; - }; - updateUserOld: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["PrivateUserAccountModel"]; + "application/json": components["schemas"]["QuickStartModel"]; }; }; /** Bad Request */ @@ -4482,16 +8871,20 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["UserUpdateRequestDto"]; + }; + get_4: { + parameters: { + path: { + projectId: number; }; }; - }; - delete: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["ProjectModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -4526,13 +8919,17 @@ export interface operations { }; }; }; - /** Updates current user's password. Invalidates all previous sessions upon success. */ - updateUserPassword: { + editProject: { + parameters: { + path: { + projectId: number; + }; + }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["JwtAuthenticationResponse"]; + "application/json": components["schemas"]["ProjectModel"]; }; }; /** Bad Request */ @@ -4570,19 +8967,19 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["UserUpdatePasswordRequestDto"]; + "application/json": components["schemas"]["EditProjectRequest"]; }; }; }; - /** Enables TOTP-based two-factor authentication. Invalidates all previous sessions upon success. */ - enableMfa: { + deleteProject: { + parameters: { + path: { + projectId: number; + }; + }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["JwtAuthenticationResponse"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -4616,19 +9013,19 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["UserTotpEnableRequestDto"]; + }; + get_5: { + parameters: { + path: { + id: number; + projectId: number; }; }; - }; - /** Disables TOTP-based two-factor authentication. Invalidates all previous sessions upon success. */ - disableMfa: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["JwtAuthenticationResponse"]; + "application/json": components["schemas"]["WebhookConfigModel"]; }; }; /** Bad Request */ @@ -4664,19 +9061,19 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["UserTotpDisableRequestDto"]; + }; + update: { + parameters: { + path: { + id: number; + projectId: number; }; }; - }; - /** Regenerates multi-factor authentication recovery codes */ - regenerateRecoveryCodes: { responses: { /** OK */ 200: { content: { - "application/json": string[]; + "application/json": components["schemas"]["WebhookConfigModel"]; }; }; /** Bad Request */ @@ -4714,18 +9111,20 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["UserMfaRecoveryRequestDto"]; + "application/json": components["schemas"]["WebhookConfigRequest"]; }; }; }; - uploadAvatar: { + delete_1: { + parameters: { + path: { + id: number; + projectId: number; + }; + }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["PrivateUserAccountModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -4759,23 +9158,26 @@ export interface operations { }; }; }; - requestBody: { - content: { - "multipart/form-data": { - /** Format: binary */ - avatar: string; - }; + }; + /** Set user's granular (scope-based) direct project permission */ + setUsersPermissions: { + parameters: { + path: { + userId: number; + projectId: number; + }; + query: { + /** Granted scopes */ + scopes?: string[]; + languages?: number[]; + translateLanguages?: number[]; + viewLanguages?: number[]; + stateChangeLanguages?: number[]; }; }; - }; - removeAvatar: { responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["PrivateUserAccountModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -4810,10 +9212,24 @@ export interface operations { }; }; }; - setPreferredOrganization: { + setUsersPermissions_1: { parameters: { path: { - organizationId: number; + userId: number; + permissionType: + | "NONE" + | "VIEW" + | "TRANSLATE" + | "REVIEW" + | "EDIT" + | "MANAGE"; + projectId: number; + }; + query: { + languages?: number[]; + translateLanguages?: number[]; + viewLanguages?: number[]; + stateChangeLanguages?: number[]; }; }; responses: { @@ -4853,10 +9269,12 @@ export interface operations { }; }; }; - setLanguage: { + /** Removes user's direct project permission, explicitly set for the project. User will have now base permissions from organization or no permission if they're not organization member. */ + setOrganizationBase: { parameters: { path: { - languageTag: string; + userId: number; + projectId: number; }; }; responses: { @@ -4896,20 +9314,16 @@ export interface operations { }; }; }; - /** Marks guide step as completed */ - completeGuideStep: { + revokePermission: { parameters: { path: { - step: string; + projectId: number; + userId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["QuickStartModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -4944,18 +9358,17 @@ export interface operations { }; }; }; - /** Sets open state of the quick start guide */ - setOpenState: { + getPerLanguageAutoTranslationSettings: { parameters: { path: { - open: boolean; + projectId: number; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["QuickStartModel"]; + "application/json": components["schemas"]["CollectionModelAutoTranslationConfigModel"]; }; }; /** Bad Request */ @@ -4992,18 +9405,17 @@ export interface operations { }; }; }; - /** Sets finished state of the quick start guide */ - setFinishedState: { + setPerLanguageAutoTranslationSettings: { parameters: { path: { - finished: boolean; + projectId: number; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["QuickStartModel"]; + "application/json": components["schemas"]["CollectionModelAutoTranslationConfigModel"]; }; }; /** Bad Request */ @@ -5039,10 +9451,16 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["AutoTranslationSettingsDto"][]; + }; + }; }; - get_4: { + update_1: { parameters: { path: { + id: number; projectId: number; }; }; @@ -5050,7 +9468,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ProjectModel"]; + "application/json": components["schemas"]["NamespaceModel"]; }; }; /** Bad Request */ @@ -5086,8 +9504,13 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateNamespaceDto"]; + }; + }; }; - editProject: { + getMachineTranslationSettings: { parameters: { path: { projectId: number; @@ -5097,7 +9520,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ProjectModel"]; + "application/json": components["schemas"]["CollectionModelLanguageConfigItemModel"]; }; }; /** Bad Request */ @@ -5133,13 +9556,8 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["EditProjectRequest"]; - }; - }; }; - deleteProject: { + setMachineTranslationSettings: { parameters: { path: { projectId: number; @@ -5147,7 +9565,11 @@ export interface operations { }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["CollectionModelLanguageConfigItemModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -5181,8 +9603,14 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["SetMachineTranslationSettingsDto"]; + }; + }; }; - get_5: { + /** Returns languages, in which key is disabled */ + getDisabledLanguages: { parameters: { path: { id: number; @@ -5193,7 +9621,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["WebhookConfigModel"]; + "application/json": components["schemas"]["CollectionModelLanguageModel"]; }; }; /** Bad Request */ @@ -5230,7 +9658,8 @@ export interface operations { }; }; }; - update: { + /** Sets languages, in which key is disabled */ + setDisabledLanguages: { parameters: { path: { id: number; @@ -5241,7 +9670,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["WebhookConfigModel"]; + "application/json": components["schemas"]["CollectionModelLanguageModel"]; }; }; /** Bad Request */ @@ -5279,11 +9708,12 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["WebhookConfigRequest"]; + "application/json": components["schemas"]["SetDisabledLanguagesRequest"]; }; }; }; - delete_1: { + /** Edits key name, translations, tags, screenshots, and other data */ + complexEdit: { parameters: { path: { id: number; @@ -5292,7 +9722,11 @@ export interface operations { }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["KeyWithDataModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -5326,26 +9760,26 @@ export interface operations { }; }; }; - }; - /** Set user's granular (scope-based) direct project permission */ - setUsersPermissions: { - parameters: { - path: { - userId: number; - projectId: number; + requestBody: { + content: { + "application/json": components["schemas"]["ComplexEditKeyDto"]; }; - query: { - /** Granted scopes */ - scopes?: string[]; - languages?: number[]; - translateLanguages?: number[]; - viewLanguages?: number[]; - stateChangeLanguages?: number[]; + }; + }; + get_6: { + parameters: { + path: { + id: number; + projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["KeyModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -5380,29 +9814,20 @@ export interface operations { }; }; }; - setUsersPermissions_1: { + edit: { parameters: { path: { - userId: number; - permissionType: - | "NONE" - | "VIEW" - | "TRANSLATE" - | "REVIEW" - | "EDIT" - | "MANAGE"; + id: number; projectId: number; }; - query: { - languages?: number[]; - translateLanguages?: number[]; - viewLanguages?: number[]; - stateChangeLanguages?: number[]; - }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["KeyModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -5436,18 +9861,25 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["EditKeyDto"]; + }; + }; }; - /** Removes user's direct project permission, explicitly set for the project. User will have now base permissions from organization or no permission if they're not organization member. */ - setOrganizationBase: { + inviteUser: { parameters: { path: { - userId: number; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["ProjectInvitationModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -5481,17 +9913,26 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["ProjectInviteUserDto"]; + }; + }; }; - revokePermission: { + get_8: { parameters: { path: { + contentStorageId: number; projectId: number; - userId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["ContentStorageModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -5526,9 +9967,10 @@ export interface operations { }; }; }; - getPerLanguageAutoTranslationSettings: { + update_3: { parameters: { path: { + contentStorageId: number; projectId: number; }; }; @@ -5536,7 +9978,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelAutoTranslationConfigModel"]; + "application/json": components["schemas"]["ContentStorageModel"]; }; }; /** Bad Request */ @@ -5572,20 +10014,22 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["ContentStorageRequest"]; + }; + }; }; - setPerLanguageAutoTranslationSettings: { + delete_6: { parameters: { path: { + contentStorageId: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["CollectionModelAutoTranslationConfigModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -5619,13 +10063,8 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["AutoTranslationSettingsDto"][]; - }; - }; }; - update_1: { + get_9: { parameters: { path: { id: number; @@ -5636,7 +10075,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["NamespaceModel"]; + "application/json": components["schemas"]["ContentDeliveryConfigModel"]; }; }; /** Bad Request */ @@ -5672,15 +10111,11 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateNamespaceDto"]; - }; - }; }; - getMachineTranslationSettings: { + update_4: { parameters: { path: { + id: number; projectId: number; }; }; @@ -5688,7 +10123,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelLanguageConfigItemModel"]; + "application/json": components["schemas"]["ContentDeliveryConfigModel"]; }; }; /** Bad Request */ @@ -5724,20 +10159,23 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["ContentDeliveryConfigRequest"]; + }; + }; }; - setMachineTranslationSettings: { + /** Immediately publishes content to the configured Content Delivery */ + post: { parameters: { path: { + id: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["CollectionModelLanguageConfigItemModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -5771,14 +10209,8 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["SetMachineTranslationSettingsDto"]; - }; - }; }; - /** Returns languages, in which key is disabled */ - getDisabledLanguages: { + delete_7: { parameters: { path: { id: number; @@ -5787,11 +10219,7 @@ export interface operations { }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["CollectionModelLanguageModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -5826,11 +10254,10 @@ export interface operations { }; }; }; - /** Sets languages, in which key is disabled */ - setDisabledLanguages: { + /** Returns default auto translation settings for project (deprecated: use per language config with null language id) */ + getAutoTranslationSettings: { parameters: { path: { - id: number; projectId: number; }; }; @@ -5838,7 +10265,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelLanguageModel"]; + "application/json": components["schemas"]["AutoTranslationConfigModel"]; }; }; /** Bad Request */ @@ -5874,17 +10301,11 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["SetDisabledLanguagesRequest"]; - }; - }; }; - /** Edits key name, translations, tags, screenshots, and other data */ - complexEdit: { + /** Sets default auto-translation settings for project (deprecated: use per language config with null language id) */ + setAutoTranslationSettings: { parameters: { path: { - id: number; projectId: number; }; }; @@ -5892,7 +10313,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["KeyWithDataModel"]; + "application/json": components["schemas"]["AutoTranslationConfigModel"]; }; }; /** Bad Request */ @@ -5930,24 +10351,19 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ComplexEditKeyDto"]; + "application/json": components["schemas"]["AutoTranslationSettingsDto"]; }; }; }; - get_6: { + executeComplexTagOperation: { parameters: { path: { - id: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["KeyModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -5981,11 +10397,17 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["ComplexTagKeysRequest"]; + }; + }; }; - edit: { + /** Tags a key with tag. If tag with provided name doesn't exist, it is created */ + tagKey: { parameters: { path: { - id: number; + keyId: number; projectId: number; }; }; @@ -5993,7 +10415,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["KeyModel"]; + "application/json": components["schemas"]["TagModel"]; }; }; /** Bad Request */ @@ -6031,23 +10453,22 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["EditKeyDto"]; + "application/json": components["schemas"]["TagKeyDto"]; }; }; }; - inviteUser: { + /** Resolves translation conflict. The old translation will be overridden. */ + resolveTranslationSetOverride: { parameters: { path: { + languageId: number; + translationId: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["ProjectInvitationModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -6081,26 +10502,19 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["ProjectInviteUserDto"]; - }; - }; }; - get_8: { + /** Resolves translation conflict. The old translation will be kept. */ + resolveTranslationSetKeepExisting: { parameters: { path: { - contentStorageId: number; + languageId: number; + translationId: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["ContentStorageModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -6135,20 +10549,17 @@ export interface operations { }; }; }; - update_3: { + /** Resolves all translation conflicts for provided language. The old translations will be overridden. */ + resolveTranslationSetOverride_2: { parameters: { path: { - contentStorageId: number; + languageId: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["ContentStorageModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -6182,16 +10593,12 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["ContentStorageRequest"]; - }; - }; }; - delete_6: { + /** Resolves all translation conflicts for provided language. The old translations will be kept. */ + resolveTranslationSetKeepExisting_2: { parameters: { path: { - contentStorageId: number; + languageId: number; projectId: number; }; }; @@ -6232,20 +10639,18 @@ export interface operations { }; }; }; - get_9: { + /** Sets existing language to pair with language to import. Data will be imported to selected existing language when applied. */ + selectExistingLanguage: { parameters: { path: { - id: number; + importLanguageId: number; + existingLanguageId: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["ContentDeliveryConfigModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -6280,20 +10685,17 @@ export interface operations { }; }; }; - update_4: { + /** Resets existing language paired with language to import. */ + resetExistingLanguage: { parameters: { path: { - id: number; + importLanguageId: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["ContentDeliveryConfigModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -6323,21 +10725,16 @@ export interface operations { content: { "application/json": | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ContentDeliveryConfigRequest"]; + | components["schemas"]["ErrorResponseBody"]; + }; }; }; }; - /** Immediately publishes content to the configured Content Delivery */ - post: { + /** Sets namespace for file to import. */ + selectNamespace: { parameters: { path: { - id: number; + fileId: number; projectId: number; }; }; @@ -6377,17 +10774,30 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["SetFileNamespaceRequest"]; + }; + }; }; - delete_7: { + /** Imports the data prepared in previous step. Streams current status. */ + applyImportStreaming: { parameters: { + query: { + /** Whether override or keep all translations with unresolved conflicts */ + forceMode?: "OVERRIDE" | "KEEP" | "NO_FORCE"; + }; path: { - id: number; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/x-ndjson": components["schemas"]["StreamingResponseBody"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6422,20 +10832,20 @@ export interface operations { }; }; }; - /** Returns default auto translation settings for project (deprecated: use per language config with null language id) */ - getAutoTranslationSettings: { + /** Imports the data prepared in previous step */ + applyImport: { parameters: { + query: { + /** Whether override or keep all translations with unresolved conflicts */ + forceMode?: "OVERRIDE" | "KEEP" | "NO_FORCE"; + }; path: { projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["AutoTranslationConfigModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -6470,8 +10880,8 @@ export interface operations { }; }; }; - /** Sets default auto-translation settings for project (deprecated: use per language config with null language id) */ - setAutoTranslationSettings: { + /** Returns import settings for the authenticated user and the project. */ + get_10: { parameters: { path: { projectId: number; @@ -6481,7 +10891,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["AutoTranslationConfigModel"]; + "application/json": components["schemas"]["ImportSettingsModel"]; }; }; /** Bad Request */ @@ -6517,13 +10927,9 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["AutoTranslationSettingsDto"]; - }; - }; }; - executeComplexTagOperation: { + /** Stores import settings for the authenticated user and the project. */ + store: { parameters: { path: { projectId: number; @@ -6531,7 +10937,11 @@ export interface operations { }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["ImportSettingsModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6567,25 +10977,21 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ComplexTagKeysRequest"]; + "application/json": components["schemas"]["ImportSettingsRequest"]; }; }; }; - /** Tags a key with tag. If tag with provided name doesn't exist, it is created */ - tagKey: { + /** Stops batch operation if possible. */ + cancel: { parameters: { path: { - keyId: number; + id: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["TagModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -6619,24 +11025,22 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["TagKeyDto"]; - }; - }; }; - /** Resolves translation conflict. The old translation will be overridden. */ - resolveTranslationSetOverride: { + setTranslationState: { parameters: { path: { - languageId: number; translationId: number; + state: "TRANSLATED" | "REVIEWED"; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["TranslationModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6671,18 +11075,22 @@ export interface operations { }; }; }; - /** Resolves translation conflict. The old translation will be kept. */ - resolveTranslationSetKeepExisting: { + setState: { parameters: { path: { - languageId: number; translationId: number; + commentId: number; + state: "RESOLUTION_NOT_NEEDED" | "NEEDS_RESOLUTION" | "RESOLVED"; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["TranslationCommentModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6717,17 +11125,21 @@ export interface operations { }; }; }; - /** Resolves all translation conflicts for provided language. The old translations will be overridden. */ - resolveTranslationSetOverride_2: { + get_14: { parameters: { path: { - languageId: number; + translationId: number; + commentId: number; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["TranslationCommentModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6762,17 +11174,21 @@ export interface operations { }; }; }; - /** Resolves all translation conflicts for provided language. The old translations will be kept. */ - resolveTranslationSetKeepExisting_2: { + update_5: { parameters: { path: { - languageId: number; + commentId: number; + translationId: number; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["TranslationCommentModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6806,13 +11222,17 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["TranslationCommentDto"]; + }; + }; }; - /** Sets existing language to pair with language to import. Data will be imported to selected existing language when applied. */ - selectExistingLanguage: { + delete_8: { parameters: { path: { - importLanguageId: number; - existingLanguageId: number; + translationId: number; + commentId: number; projectId: number; }; }; @@ -6853,17 +11273,22 @@ export interface operations { }; }; }; - /** Resets existing language paired with language to import. */ - resetExistingLanguage: { + /** Set's "outdated" flag indicating the base translation was changed without updating current translation. */ + setOutdated: { parameters: { path: { - importLanguageId: number; + translationId: number; + state: boolean; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["TranslationModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6898,17 +11323,21 @@ export interface operations { }; }; }; - /** Sets namespace for file to import. */ - selectNamespace: { + /** Removes "auto translated" indication */ + dismissAutoTranslatedState: { parameters: { path: { - fileId: number; + translationId: number; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["TranslationModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6942,18 +11371,66 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["SetFileNamespaceRequest"]; - }; - }; }; - /** Imports the data prepared in previous step. Streams current status. */ - applyImportStreaming: { + getTranslations: { parameters: { query: { - /** Whether override or keep all translations with unresolved conflicts */ - forceMode?: "OVERRIDE" | "KEEP" | "NO_FORCE"; + /** Cursor to get next data */ + cursor?: string; + /** + * Translation state in the format: languageTag,state. You can use this parameter multiple times. + * + * When used with multiple states for same language it is applied with logical OR. + * + * When used with multiple languages, it is applied with logical AND. + */ + filterState?: string[]; + /** + * Languages to be contained in response. + * + * To add multiple languages, repeat this param (eg. ?languages=en&languages=de) + */ + languages?: string[]; + /** String to search in key name or translation text */ + search?: string; + /** Selects key with provided names. Use this param multiple times to fetch more keys. */ + filterKeyName?: string[]; + /** Selects key with provided ID. Use this param multiple times to fetch more keys. */ + filterKeyId?: number[]; + /** Selects only keys for which the translation is missing in any returned language. It only filters for translations included in returned languages. */ + filterUntranslatedAny?: boolean; + /** Selects only keys, where translation is provided in any language */ + filterTranslatedAny?: boolean; + /** Selects only keys where the translation is missing for the specified language. The specified language must be included in the returned languages. Otherwise, this filter doesn't apply. */ + filterUntranslatedInLang?: string; + /** Selects only keys, where translation is provided in specified language */ + filterTranslatedInLang?: string; + /** Selects only keys with screenshots */ + filterHasScreenshot?: boolean; + /** Selects only keys without screenshots */ + filterHasNoScreenshot?: boolean; + /** + * Filter namespaces. + * + * To filter default namespace, set to empty string. + */ + filterNamespace?: string[]; + /** Selects only keys with provided tag */ + filterTag?: string[]; + /** Selects only keys, where translation in provided langs is in outdated state */ + filterOutdatedLanguage?: string[]; + /** Selects only keys, where translation in provided langs is not in outdated state */ + filterNotOutdatedLanguage?: string[]; + /** Selects only key affected by activity with specidfied revision ID */ + filterRevisionId?: number[]; + /** Select only keys which were not successfully translated by batch job with provided id */ + filterFailedKeysOfJob?: number; + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; }; path: { projectId: number; @@ -6963,7 +11440,7 @@ export interface operations { /** OK */ 200: { content: { - "application/x-ndjson": components["schemas"]["StreamingResponseBody"]; + "application/json": components["schemas"]["KeysWithTranslationsPageModel"]; }; }; /** Bad Request */ @@ -7000,20 +11477,20 @@ export interface operations { }; }; }; - /** Imports the data prepared in previous step */ - applyImport: { + /** Sets translations for existing key */ + setTranslations: { parameters: { - query: { - /** Whether override or keep all translations with unresolved conflicts */ - forceMode?: "OVERRIDE" | "KEEP" | "NO_FORCE"; - }; path: { projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["SetTranslationsResponseModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -7047,9 +11524,14 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["SetTranslationsWithKeyDto"]; + }; + }; }; - /** Returns import settings for the authenticated user and the project. */ - get_10: { + /** Sets translations for existing key or creates new key and sets the translations to it. */ + createOrUpdateTranslations: { parameters: { path: { projectId: number; @@ -7059,7 +11541,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ImportSettingsModel"]; + "application/json": components["schemas"]["SetTranslationsResponseModel"]; }; }; /** Bad Request */ @@ -7095,21 +11577,23 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["SetTranslationsWithKeyDto"]; + }; + }; }; - /** Stores import settings for the authenticated user and the project. */ - store: { + /** Transfers project's ownership to organization */ + transferProjectToOrganization: { parameters: { path: { projectId: number; + organizationId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["ImportSettingsModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -7143,17 +11627,10 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["ImportSettingsRequest"]; - }; - }; }; - /** Stops batch operation if possible. */ - cancel: { + leaveProject: { parameters: { path: { - id: number; projectId: number; }; }; @@ -7194,11 +11671,10 @@ export interface operations { }; }; }; - setTranslationState: { + get_16: { parameters: { path: { - translationId: number; - state: "TRANSLATED" | "REVIEWED"; + languageId: number; projectId: number; }; }; @@ -7206,7 +11682,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["TranslationModel"]; + "application/json": components["schemas"]["LanguageModel"]; }; }; /** Bad Request */ @@ -7243,12 +11719,10 @@ export interface operations { }; }; }; - setState: { + editLanguage: { parameters: { path: { - translationId: number; - commentId: number; - state: "RESOLUTION_NOT_NEEDED" | "NEEDS_RESOLUTION" | "RESOLVED"; + languageId: number; projectId: number; }; }; @@ -7256,7 +11730,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["TranslationCommentModel"]; + "application/json": components["schemas"]["LanguageModel"]; }; }; /** Bad Request */ @@ -7292,22 +11766,22 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["LanguageRequest"]; + }; + }; }; - get_14: { + deleteLanguage_2: { parameters: { path: { - translationId: number; - commentId: number; + languageId: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["TranslationCommentModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -7342,11 +11816,10 @@ export interface operations { }; }; }; - update_5: { + setLanguagePromptCustomization: { parameters: { path: { - commentId: number; - translationId: number; + languageId: number; projectId: number; }; }; @@ -7354,7 +11827,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["TranslationCommentModel"]; + "application/json": components["schemas"]["LanguageAiPromptCustomizationModel"]; }; }; /** Bad Request */ @@ -7392,17 +11865,31 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["TranslationCommentDto"]; + "application/json": components["schemas"]["SetLanguagePromptCustomizationRequest"]; }; }; }; - delete_8: { + /** + * Uses enabled auto-translation methods. + * You need to set at least one of useMachineTranslation or useTranslationMemory to true. + * + * This will replace the the existing translation with the result obtained from specified source! + */ + autoTranslate: { parameters: { path: { - translationId: number; - commentId: number; + keyId: number; projectId: number; }; + query: { + /** + * Tags of languages to auto-translate. + * When no languages provided, it translates only untranslated languages. + */ + languages?: string[]; + useMachineTranslation?: boolean; + useTranslationMemory?: boolean; + }; }; responses: { /** OK */ @@ -7441,12 +11928,9 @@ export interface operations { }; }; }; - /** Set's "outdated" flag indicating the base translation was changed without updating current translation. */ - setOutdated: { + uploadAvatar_1: { parameters: { path: { - translationId: number; - state: boolean; projectId: number; }; }; @@ -7454,7 +11938,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["TranslationModel"]; + "application/json": components["schemas"]["ProjectModel"]; }; }; /** Bad Request */ @@ -7490,12 +11974,18 @@ export interface operations { }; }; }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + avatar: string; + }; + }; + }; }; - /** Removes "auto translated" indication */ - dismissAutoTranslatedState: { + removeAvatar_1: { parameters: { path: { - translationId: number; projectId: number; }; }; @@ -7503,7 +11993,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["TranslationModel"]; + "application/json": components["schemas"]["ProjectModel"]; }; }; /** Bad Request */ @@ -7540,66 +12030,8 @@ export interface operations { }; }; }; - getTranslations: { + getPromptProjectCustomization: { parameters: { - query: { - /** Cursor to get next data */ - cursor?: string; - /** - * Translation state in the format: languageTag,state. You can use this parameter multiple times. - * - * When used with multiple states for same language it is applied with logical OR. - * - * When used with multiple languages, it is applied with logical AND. - */ - filterState?: string[]; - /** - * Languages to be contained in response. - * - * To add multiple languages, repeat this param (eg. ?languages=en&languages=de) - */ - languages?: string[]; - /** String to search in key name or translation text */ - search?: string; - /** Selects key with provided names. Use this param multiple times to fetch more keys. */ - filterKeyName?: string[]; - /** Selects key with provided ID. Use this param multiple times to fetch more keys. */ - filterKeyId?: number[]; - /** Selects only keys for which the translation is missing in any returned language. It only filters for translations included in returned languages. */ - filterUntranslatedAny?: boolean; - /** Selects only keys, where translation is provided in any language */ - filterTranslatedAny?: boolean; - /** Selects only keys where the translation is missing for the specified language. The specified language must be included in the returned languages. Otherwise, this filter doesn't apply. */ - filterUntranslatedInLang?: string; - /** Selects only keys, where translation is provided in specified language */ - filterTranslatedInLang?: string; - /** Selects only keys with screenshots */ - filterHasScreenshot?: boolean; - /** Selects only keys without screenshots */ - filterHasNoScreenshot?: boolean; - /** - * Filter namespaces. - * - * To filter default namespace, set to empty string. - */ - filterNamespace?: string[]; - /** Selects only keys with provided tag */ - filterTag?: string[]; - /** Selects only keys, where translation in provided langs is in outdated state */ - filterOutdatedLanguage?: string[]; - /** Selects only keys, where translation in provided langs is not in outdated state */ - filterNotOutdatedLanguage?: string[]; - /** Selects only key affected by activity with specidfied revision ID */ - filterRevisionId?: number[]; - /** Select only keys which were not successfully translated by batch job with provided id */ - filterFailedKeysOfJob?: number; - /** Zero-based page index (0..N) */ - page?: number; - /** The size of the page to be returned */ - size?: number; - /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ - sort?: string[]; - }; path: { projectId: number; }; @@ -7608,7 +12040,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["KeysWithTranslationsPageModel"]; + "application/json": components["schemas"]["ProjectAiPromptCustomizationModel"]; }; }; /** Bad Request */ @@ -7645,8 +12077,7 @@ export interface operations { }; }; }; - /** Sets translations for existing key */ - setTranslations: { + setPromptProjectCustomization: { parameters: { path: { projectId: number; @@ -7656,7 +12087,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["SetTranslationsResponseModel"]; + "application/json": components["schemas"]["ProjectAiPromptCustomizationModel"]; }; }; /** Bad Request */ @@ -7694,22 +12125,21 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SetTranslationsWithKeyDto"]; + "application/json": components["schemas"]["SetProjectPromptCustomizationRequest"]; }; }; }; - /** Sets translations for existing key or creates new key and sets the translations to it. */ - createOrUpdateTranslations: { + get_18: { parameters: { path: { - projectId: number; + id: number; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["SetTranslationsResponseModel"]; + "application/json": components["schemas"]["PatModel"]; }; }; /** Bad Request */ @@ -7745,23 +12175,21 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["SetTranslationsWithKeyDto"]; - }; - }; }; - /** Transfers project's ownership to organization */ - transferProjectToOrganization: { + /** Updates Personal Access Token */ + update_7: { parameters: { path: { - projectId: number; - organizationId: number; + id: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["PatModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -7795,11 +12223,17 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdatePatDto"]; + }; + }; }; - leaveProject: { + /** Deletes Personal Access Token */ + delete_10: { parameters: { path: { - projectId: number; + id: number; }; }; responses: { @@ -7839,18 +12273,18 @@ export interface operations { }; }; }; - get_16: { + /** Regenerates Personal Access Token. It generates new token value and updates its time of expiration. */ + regenerate: { parameters: { path: { - languageId: number; - projectId: number; + id: number; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["LanguageModel"]; + "application/json": components["schemas"]["RevealedPatModel"]; }; }; /** Bad Request */ @@ -7886,21 +12320,23 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["RegeneratePatDto"]; + }; + }; }; - editLanguage: { + /** Sets user role in organization. Owner or Member. */ + setUserRole: { parameters: { path: { - languageId: number; - projectId: number; + organizationId: number; + userId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["LanguageModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -7936,15 +12372,19 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["LanguageRequest"]; + "application/json": components["schemas"]["SetOrganizationRoleDto"]; }; }; }; - deleteLanguage_2: { + /** Set default granular (scope-based) permissions for organization users, who don't have direct project permissions set. */ + setBasePermissions: { parameters: { path: { - languageId: number; - projectId: number; + organizationId: number; + }; + query: { + /** Granted scopes to all projects for all organization users without direct project permissions set. */ + scopes: string[]; }; }; responses: { @@ -7984,20 +12424,23 @@ export interface operations { }; }; }; - setLanguagePromptCustomization: { + /** Sets default (level-based) permission for organization */ + setBasePermissions_1: { parameters: { path: { - languageId: number; - projectId: number; + organizationId: number; + permissionType: + | "NONE" + | "VIEW" + | "TRANSLATE" + | "REVIEW" + | "EDIT" + | "MANAGE"; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["LanguageAiPromptCustomizationModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -8031,37 +12474,20 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["SetLanguagePromptCustomizationRequest"]; - }; - }; }; - /** - * Uses enabled auto-translation methods. - * You need to set at least one of useMachineTranslation or useTranslationMemory to true. - * - * This will replace the the existing translation with the result obtained from specified source! - */ - autoTranslate: { + get_20: { parameters: { path: { - keyId: number; - projectId: number; - }; - query: { - /** - * Tags of languages to auto-translate. - * When no languages provided, it translates only untranslated languages. - */ - languages?: string[]; - useMachineTranslation?: boolean; - useTranslationMemory?: boolean; + id: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["OrganizationModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -8096,17 +12522,17 @@ export interface operations { }; }; }; - uploadAvatar_1: { + update_8: { parameters: { path: { - projectId: number; + id: number; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["ProjectModel"]; + "application/json": components["schemas"]["OrganizationModel"]; }; }; /** Bad Request */ @@ -8144,26 +12570,20 @@ export interface operations { }; requestBody: { content: { - "multipart/form-data": { - /** Format: binary */ - avatar: string; - }; + "application/json": components["schemas"]["OrganizationDto"]; }; }; }; - removeAvatar_1: { + /** Deletes organization and all its data including projects */ + delete_11: { parameters: { path: { - projectId: number; + id: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["ProjectModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -8198,19 +12618,16 @@ export interface operations { }; }; }; - getPromptProjectCustomization: { + /** Remove current user from organization */ + leaveOrganization: { parameters: { path: { - projectId: number; + id: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["ProjectAiPromptCustomizationModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -8245,17 +12662,18 @@ export interface operations { }; }; }; - setPromptProjectCustomization: { + /** Generates invitation link for organization, so users can join organization. The invitation can also be sent to an e-mail address. */ + inviteUser_1: { parameters: { path: { - projectId: number; + id: number; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["ProjectAiPromptCustomizationModel"]; + "application/json": components["schemas"]["OrganizationInvitationModel"]; }; }; /** Bad Request */ @@ -8293,11 +12711,11 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SetProjectPromptCustomizationRequest"]; + "application/json": components["schemas"]["OrganizationInviteUserDto"]; }; }; }; - get_18: { + uploadAvatar_2: { parameters: { path: { id: number; @@ -8307,7 +12725,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["PatModel"]; + "application/json": components["schemas"]["OrganizationModel"]; }; }; /** Bad Request */ @@ -8343,9 +12761,16 @@ export interface operations { }; }; }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + avatar: string; + }; + }; + }; }; - /** Updates Personal Access Token */ - update_7: { + removeAvatar_2: { parameters: { path: { id: number; @@ -8355,7 +12780,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["PatModel"]; + "application/json": components["schemas"]["OrganizationModel"]; }; }; /** Bad Request */ @@ -8391,14 +12816,8 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdatePatDto"]; - }; - }; }; - /** Deletes Personal Access Token */ - delete_10: { + getPerProjectPreferences: { parameters: { path: { id: number; @@ -8406,7 +12825,11 @@ export interface operations { }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["NotificationPreferencesDto"]; + }; + }; /** Bad Request */ 400: { content: { @@ -8441,8 +12864,7 @@ export interface operations { }; }; }; - /** Regenerates Personal Access Token. It generates new token value and updates its time of expiration. */ - regenerate: { + updatePerProjectPreferences: { parameters: { path: { id: number; @@ -8452,7 +12874,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["RevealedPatModel"]; + "application/json": components["schemas"]["NotificationPreferencesDto"]; }; }; /** Bad Request */ @@ -8490,21 +12912,19 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["RegeneratePatDto"]; + "application/json": components["schemas"]["NotificationPreferencesDto"]; }; }; }; - /** Sets user role in organization. Owner or Member. */ - setUserRole: { + deletePerProjectPreferences: { parameters: { path: { - organizationId: number; - userId: number; + id: number; }; }; responses: { - /** OK */ - 200: unknown; + /** No Content */ + 204: never; /** Bad Request */ 400: { content: { @@ -8538,26 +12958,15 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["SetOrganizationRoleDto"]; - }; - }; }; - /** Set default granular (scope-based) permissions for organization users, who don't have direct project permissions set. */ - setBasePermissions: { - parameters: { - path: { - organizationId: number; - }; - query: { - /** Granted scopes to all projects for all organization users without direct project permissions set. */ - scopes: string[]; - }; - }; + getGlobalPreferences: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["NotificationPreferencesDto"]; + }; + }; /** Bad Request */ 400: { content: { @@ -8592,23 +13001,14 @@ export interface operations { }; }; }; - /** Sets default (level-based) permission for organization */ - setBasePermissions_1: { - parameters: { - path: { - organizationId: number; - permissionType: - | "NONE" - | "VIEW" - | "TRANSLATE" - | "REVIEW" - | "EDIT" - | "MANAGE"; - }; - }; + updateGlobalPreferences: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["NotificationPreferencesDto"]; + }; + }; /** Bad Request */ 400: { content: { @@ -8642,18 +13042,18 @@ export interface operations { }; }; }; - }; - get_20: { - parameters: { - path: { - id: number; + requestBody: { + content: { + "application/json": components["schemas"]["NotificationPreferencesDto"]; }; }; + }; + setLicenseKey: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["OrganizationModel"]; + "application/json": components["schemas"]["EeSubscriptionModel"]; }; }; /** Bad Request */ @@ -8689,20 +13089,17 @@ export interface operations { }; }; }; - }; - update_8: { - parameters: { - path: { - id: number; + requestBody: { + content: { + "application/json": components["schemas"]["SetLicenseKeyDto"]; }; }; + }; + /** This will remove the licence key from the instance. */ + release: { responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["OrganizationModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -8736,22 +13133,16 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["OrganizationDto"]; - }; - }; }; - /** Deletes organization and all its data including projects */ - delete_11: { - parameters: { - path: { - id: number; - }; - }; + /** This will refresh the subscription information from the license server and update the subscription info. */ + refreshSubscription: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["EeSubscriptionModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -8786,16 +13177,19 @@ export interface operations { }; }; }; - /** Remove current user from organization */ - leaveOrganization: { + update_9: { parameters: { path: { - id: number; + apiKeyId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["ApiKeyModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -8829,21 +13223,21 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["V2EditApiKeyDto"]; + }; + }; }; - /** Generates invitation link for organization, so users can join organization. The invitation can also be sent to an e-mail address. */ - inviteUser_1: { + delete_13: { parameters: { path: { - id: number; + apiKeyId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["OrganizationInvitationModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -8877,23 +13271,18 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["OrganizationInviteUserDto"]; - }; - }; }; - uploadAvatar_2: { + regenerate_1: { parameters: { path: { - id: number; + apiKeyId: number; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["OrganizationModel"]; + "application/json": components["schemas"]["RevealedApiKeyModel"]; }; }; /** Bad Request */ @@ -8931,26 +13320,20 @@ export interface operations { }; requestBody: { content: { - "multipart/form-data": { - /** Format: binary */ - avatar: string; - }; + "application/json": components["schemas"]["RegenerateApiKeyDto"]; }; }; }; - removeAvatar_2: { + /** Enables previously disabled user. */ + enableUser: { parameters: { path: { - id: number; + userId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["OrganizationModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -8985,14 +13368,16 @@ export interface operations { }; }; }; - setLicenseKey: { + /** Disables user account. User will not be able to log in, but their user data will be preserved, so you can enable the user later using the `enable` endpoint. */ + disableUser: { + parameters: { + path: { + userId: number; + }; + }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["EeSubscriptionModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9026,14 +13411,15 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["SetLicenseKeyDto"]; + }; + /** Set's the global role on the Tolgee Platform server. */ + setRole: { + parameters: { + path: { + userId: number; + role: "USER" | "ADMIN"; }; }; - }; - /** This will remove the licence key from the instance. */ - release: { responses: { /** OK */ 200: unknown; @@ -9071,15 +13457,11 @@ export interface operations { }; }; }; - /** This will refresh the subscription information from the license server and update the subscription info. */ - refreshSubscription: { + /** Resends email verification email to currently authenticated user. */ + sendEmailVerification: { responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["EeSubscriptionModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9114,17 +13496,13 @@ export interface operations { }; }; }; - update_9: { - parameters: { - path: { - apiKeyId: number; - }; - }; + /** Generates new JWT token permitted to sensitive operations */ + getSuperToken: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["ApiKeyModel"]; + "application/json": components["schemas"]["JwtAuthenticationResponse"]; }; }; /** Bad Request */ @@ -9162,19 +13540,18 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["V2EditApiKeyDto"]; + "application/json": components["schemas"]["SuperTokenRequest"]; }; }; }; - delete_13: { - parameters: { - path: { - apiKeyId: number; - }; - }; + generateProjectSlug: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": string; + }; + }; /** Bad Request */ 400: { content: { @@ -9208,18 +13585,18 @@ export interface operations { }; }; }; - }; - regenerate_1: { - parameters: { - path: { - apiKeyId: number; + requestBody: { + content: { + "application/json": components["schemas"]["GenerateSlugDto"]; }; }; + }; + generateOrganizationSlug: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["RevealedApiKeyModel"]; + "application/json": string; }; }; /** Bad Request */ @@ -9257,15 +13634,16 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["RegenerateApiKeyDto"]; + "application/json": components["schemas"]["GenerateSlugDto"]; }; }; }; - /** Enables previously disabled user. */ - enableUser: { + /** Pairs user account with slack account. */ + userLogin: { parameters: { - path: { - userId: number; + query: { + /** The encrypted data about the desired connection between Slack account and Tolgee account */ + data: string; }; }; responses: { @@ -9305,16 +13683,14 @@ export interface operations { }; }; }; - /** Disables user account. User will not be able to log in, but their user data will be preserved, so you can enable the user later using the `enable` endpoint. */ - disableUser: { - parameters: { - path: { - userId: number; - }; - }; + translate: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["MtResult"]; + }; + }; /** Bad Request */ 400: { content: { @@ -9348,15 +13724,13 @@ export interface operations { }; }; }; - }; - /** Set's the global role on the Tolgee Platform server. */ - setRole: { - parameters: { - path: { - userId: number; - role: "USER" | "ADMIN"; + requestBody: { + content: { + "application/json": components["schemas"]["TolgeeTranslateParams"]; }; }; + }; + report: { responses: { /** OK */ 200: unknown; @@ -9393,12 +13767,26 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["TelemetryReportRequest"]; + }; + }; }; - /** Resends email verification email to currently authenticated user. */ - sendEmailVerification: { + slackCommand: { + parameters: { + header: { + "X-Slack-Signature": string; + "X-Slack-Request-Timestamp": string; + }; + }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": string; + }; + }; /** Bad Request */ 400: { content: { @@ -9432,16 +13820,26 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": { + payload?: components["schemas"]["SlackCommandDto"]; + body?: string; + }; + }; + }; }; - /** Generates new JWT token permitted to sensitive operations */ - getSuperToken: { + /** This is triggered when interactivity event is triggered. E.g., when user clicks button provided in previous messages. */ + onInteractivityEvent: { + parameters: { + header: { + "X-Slack-Signature": string; + "X-Slack-Request-Timestamp": string; + }; + }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["JwtAuthenticationResponse"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9477,16 +13875,27 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SuperTokenRequest"]; + "application/json": string; }; }; }; - generateProjectSlug: { + /** + * This is triggered when bot event is triggered. E.g., when app is uninstalled from workspace. + * + * Heads up! The events have to be configured via Slack App configuration in Event Subscription section. + */ + fetchBotEvent: { + parameters: { + header: { + "X-Slack-Signature": string; + "X-Slack-Request-Timestamp": string; + }; + }; responses: { /** OK */ 200: { content: { - "application/json": string; + "application/json": { [key: string]: unknown }; }; }; /** Bad Request */ @@ -9524,16 +13933,16 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["GenerateSlugDto"]; + "application/json": string; }; }; }; - generateOrganizationSlug: { + getMySubscription: { responses: { /** OK */ 200: { content: { - "application/json": string; + "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; }; }; /** Bad Request */ @@ -9571,21 +13980,18 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["GenerateSlugDto"]; + "application/json": components["schemas"]["GetMySubscriptionDto"]; }; }; }; - /** Pairs user account with slack account. */ - userLogin: { - parameters: { - query: { - /** The encrypted data about the desired connection between Slack account and Tolgee account */ - data: string; - }; - }; + onLicenceSetKey: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -9619,15 +14025,16 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["SetLicenseKeyLicensingDto"]; + }; + }; }; - translate: { + reportUsage: { responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["MtResult"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9663,11 +14070,11 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["TolgeeTranslateParams"]; + "application/json": components["schemas"]["ReportUsageDto"]; }; }; }; - report: { + reportError: { responses: { /** OK */ 200: unknown; @@ -9706,24 +14113,14 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["TelemetryReportRequest"]; + "application/json": components["schemas"]["ReportErrorDto"]; }; }; }; - slackCommand: { - parameters: { - header: { - "X-Slack-Signature": string; - "X-Slack-Request-Timestamp": string; - }; - }; + releaseKey: { responses: { /** OK */ - 200: { - content: { - "application/json": string; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9757,26 +14154,20 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": { - payload?: components["schemas"]["SlackCommandDto"]; - body?: string; - }; - }; - }; - }; - /** This is triggered when interactivity event is triggered. E.g., when user clicks button provided in previous messages. */ - onInteractivityEvent: { - parameters: { - header: { - "X-Slack-Signature": string; - "X-Slack-Request-Timestamp": string; - }; - }; + requestBody: { + content: { + "application/json": components["schemas"]["ReleaseKeyDto"]; + }; + }; + }; + prepareSetLicenseKey: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["PrepareSetEeLicenceKeyModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -9812,29 +14203,14 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["PrepareSetLicenseKeyDto"]; }; }; }; - /** - * This is triggered when bot event is triggered. E.g., when app is uninstalled from workspace. - * - * Heads up! The events have to be configured via Slack App configuration in Event Subscription section. - */ - fetchBotEvent: { - parameters: { - header: { - "X-Slack-Signature": string; - "X-Slack-Request-Timestamp": string; - }; - }; + report_1: { responses: { /** OK */ - 200: { - content: { - "application/json": { [key: string]: unknown }; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9870,18 +14246,14 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["BusinessEventReportRequest"]; }; }; }; - getMySubscription: { + identify: { responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9917,16 +14289,28 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["GetMySubscriptionDto"]; + "application/json": components["schemas"]["IdentifyRequest"]; }; }; }; - onLicenceSetKey: { + /** Returns all projects where current user has any permission */ + getAll: { + parameters: { + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; + }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; + "application/hal+json": components["schemas"]["PagedModelProjectModel"]; }; }; /** Bad Request */ @@ -9962,16 +14346,16 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["SetLicenseKeyLicensingDto"]; - }; - }; }; - reportUsage: { + /** Creates a new project with languages and initial settings. */ + createProject: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["ProjectModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10007,14 +14391,31 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ReportUsageDto"]; + "application/json": components["schemas"]["CreateProjectRequest"]; }; }; }; - reportError: { + list: { + parameters: { + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; + path: { + projectId: number; + }; + }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["PagedModelWebhookConfigModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10048,16 +14449,20 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["ReportErrorDto"]; + }; + create: { + parameters: { + path: { + projectId: number; }; }; - }; - releaseKey: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["WebhookConfigModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10093,16 +14498,23 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ReleaseKeyDto"]; + "application/json": components["schemas"]["WebhookConfigRequest"]; }; }; }; - prepareSetLicenseKey: { + /** Sends a test request to the webhook */ + test: { + parameters: { + path: { + id: number; + projectId: number; + }; + }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["PrepareSetEeLicenceKeyModel"]; + "application/json": components["schemas"]["WebhookTestResponse"]; }; }; /** Bad Request */ @@ -10138,16 +14550,21 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["PrepareSetLicenseKeyDto"]; + }; + /** Returns information about keys. (KeyData, Screenshots, Translation in specified language)If key is not found, it's not included in the response. */ + getInfo: { + parameters: { + path: { + projectId: number; }; }; - }; - report_1: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["CollectionModelKeyWithDataModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10183,14 +14600,24 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["BusinessEventReportRequest"]; + "application/json": components["schemas"]["GetKeysRequestDto"]; }; }; }; - identify: { + /** Import's new keys with translations. Translations can be updated, when specified. */ + importKeys: { + parameters: { + path: { + projectId: number; + }; + }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["KeyImportResolvableResultModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10226,30 +14653,20 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["IdentifyRequest"]; + "application/json": components["schemas"]["ImportKeysResolvableDto"]; }; }; }; - /** Returns all projects where current user has any permission */ - getAll: { + /** Imports new keys with translations. If key already exists, its translations and tags are not updated. */ + importKeys_2: { parameters: { - query: { - /** Zero-based page index (0..N) */ - page?: number; - /** The size of the page to be returned */ - size?: number; - /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ - sort?: string[]; - search?: string; + path: { + projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/hal+json": components["schemas"]["PagedModelProjectModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -10283,14 +14700,23 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["ImportKeysDto"]; + }; + }; }; - /** Creates a new project with languages and initial settings. */ - createProject: { + create_1: { + parameters: { + path: { + projectId: number; + }; + }; responses: { - /** OK */ - 200: { + /** Created */ + 201: { content: { - "application/json": components["schemas"]["ProjectModel"]; + "*/*": components["schemas"]["KeyWithDataModel"]; }; }; /** Bad Request */ @@ -10328,11 +14754,11 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateProjectRequest"]; + "application/json": components["schemas"]["CreateKeyDto"]; }; }; }; - list: { + getAll_1: { parameters: { query: { /** Zero-based page index (0..N) */ @@ -10350,7 +14776,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["PagedModelWebhookConfigModel"]; + "application/json": components["schemas"]["PagedModelKeyModel"]; }; }; /** Bad Request */ @@ -10387,17 +14813,17 @@ export interface operations { }; }; }; - create: { + create_2: { parameters: { path: { projectId: number; }; }; responses: { - /** OK */ - 200: { + /** Created */ + 201: { content: { - "application/json": components["schemas"]["WebhookConfigModel"]; + "*/*": components["schemas"]["KeyWithDataModel"]; }; }; /** Bad Request */ @@ -10435,25 +14861,20 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["WebhookConfigRequest"]; + "application/json": components["schemas"]["CreateKeyDto"]; }; }; }; - /** Sends a test request to the webhook */ - test: { + /** Delete one or multiple keys by their IDs in request body. Useful for larger requests esxceeding allowed URL length. */ + delete_4: { parameters: { path: { - id: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["WebhookTestResponse"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -10487,10 +14908,22 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["DeleteKeysDto"]; + }; + }; }; - /** Returns information about keys. (KeyData, Screenshots, Translation in specified language)If key is not found, it's not included in the response. */ - getInfo: { + list_1: { parameters: { + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; path: { projectId: number; }; @@ -10499,7 +14932,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelKeyWithDataModel"]; + "application/json": components["schemas"]["PagedModelContentStorageModel"]; }; }; /** Bad Request */ @@ -10535,14 +14968,8 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["GetKeysRequestDto"]; - }; - }; }; - /** Import's new keys with translations. Translations can be updated, when specified. */ - importKeys: { + create_5: { parameters: { path: { projectId: number; @@ -10552,7 +14979,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["KeyImportResolvableResultModel"]; + "application/json": components["schemas"]["ContentStorageModel"]; }; }; /** Bad Request */ @@ -10590,20 +15017,25 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ImportKeysResolvableDto"]; + "application/json": components["schemas"]["ContentStorageRequest"]; }; }; }; - /** Imports new keys with translations. If key already exists, its translations and tags are not updated. */ - importKeys_2: { + /** Tests existing Content Storage with new configuration. (Uses existing secrets, if nulls provided) */ + testExisting: { parameters: { path: { + id: number; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["StorageTestResult"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10639,21 +15071,21 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ImportKeysDto"]; + "application/json": components["schemas"]["ContentStorageRequest"]; }; }; }; - create_1: { + test_1: { parameters: { path: { projectId: number; }; }; responses: { - /** Created */ - 201: { + /** OK */ + 200: { content: { - "*/*": components["schemas"]["KeyWithDataModel"]; + "application/json": components["schemas"]["StorageTestResult"]; }; }; /** Bad Request */ @@ -10691,11 +15123,11 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateKeyDto"]; + "application/json": components["schemas"]["ContentStorageRequest"]; }; }; }; - getAll_1: { + list_2: { parameters: { query: { /** Zero-based page index (0..N) */ @@ -10713,7 +15145,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["PagedModelKeyModel"]; + "application/json": components["schemas"]["PagedModelContentDeliveryConfigModel"]; }; }; /** Bad Request */ @@ -10750,17 +15182,17 @@ export interface operations { }; }; }; - create_2: { + create_6: { parameters: { path: { projectId: number; }; }; responses: { - /** Created */ - 201: { + /** OK */ + 200: { content: { - "*/*": components["schemas"]["KeyWithDataModel"]; + "application/json": components["schemas"]["ContentDeliveryConfigModel"]; }; }; /** Bad Request */ @@ -10798,12 +15230,11 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateKeyDto"]; + "application/json": components["schemas"]["ContentDeliveryConfigRequest"]; }; }; }; - /** Delete one or multiple keys by their IDs in request body. Useful for larger requests esxceeding allowed URL length. */ - delete_4: { + untagKeys: { parameters: { path: { projectId: number; @@ -10811,7 +15242,11 @@ export interface operations { }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["BatchJobModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10847,20 +15282,12 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["DeleteKeysDto"]; + "application/json": components["schemas"]["UntagKeysRequest"]; }; }; }; - list_1: { + tagKeys: { parameters: { - query: { - /** Zero-based page index (0..N) */ - page?: number; - /** The size of the page to be returned */ - size?: number; - /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ - sort?: string[]; - }; path: { projectId: number; }; @@ -10869,7 +15296,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["PagedModelContentStorageModel"]; + "application/json": components["schemas"]["BatchJobModel"]; }; }; /** Bad Request */ @@ -10905,8 +15332,13 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["TagKeysRequest"]; + }; + }; }; - create_5: { + setTranslationState_2: { parameters: { path: { projectId: number; @@ -10916,7 +15348,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ContentStorageModel"]; + "application/json": components["schemas"]["BatchJobModel"]; }; }; /** Bad Request */ @@ -10954,15 +15386,13 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ContentStorageRequest"]; + "application/json": components["schemas"]["SetTranslationsStateStateRequest"]; }; }; }; - /** Tests existing Content Storage with new configuration. (Uses existing secrets, if nulls provided) */ - testExisting: { + setKeysNamespace: { parameters: { path: { - id: number; projectId: number; }; }; @@ -10970,7 +15400,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["StorageTestResult"]; + "application/json": components["schemas"]["BatchJobModel"]; }; }; /** Bad Request */ @@ -11008,11 +15438,12 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ContentStorageRequest"]; + "application/json": components["schemas"]["SetKeysNamespaceRequest"]; }; }; }; - test_1: { + /** Pre-translate provided keys to provided languages by TM. */ + translate_1: { parameters: { path: { projectId: number; @@ -11022,7 +15453,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["StorageTestResult"]; + "application/json": components["schemas"]["BatchJobModel"]; }; }; /** Bad Request */ @@ -11060,20 +15491,13 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ContentStorageRequest"]; + "application/json": components["schemas"]["PreTranslationByTmRequest"]; }; }; }; - list_2: { + /** Translate provided keys to provided languages through primary MT provider. */ + machineTranslation: { parameters: { - query: { - /** Zero-based page index (0..N) */ - page?: number; - /** The size of the page to be returned */ - size?: number; - /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ - sort?: string[]; - }; path: { projectId: number; }; @@ -11082,7 +15506,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["PagedModelContentDeliveryConfigModel"]; + "application/json": components["schemas"]["BatchJobModel"]; }; }; /** Bad Request */ @@ -11118,8 +15542,13 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["MachineTranslationRequest"]; + }; + }; }; - create_6: { + deleteKeys: { parameters: { path: { projectId: number; @@ -11129,7 +15558,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ContentDeliveryConfigModel"]; + "application/json": components["schemas"]["BatchJobModel"]; }; }; /** Bad Request */ @@ -11167,11 +15596,12 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ContentDeliveryConfigRequest"]; + "application/json": components["schemas"]["DeleteKeysRequest"]; }; }; }; - untagKeys: { + /** Copy translation values from one language to other languages. */ + copyTranslations: { parameters: { path: { projectId: number; @@ -11219,11 +15649,12 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["UntagKeysRequest"]; + "application/json": components["schemas"]["CopyTranslationRequest"]; }; }; }; - tagKeys: { + /** Clear translation values for provided keys in selected languages. */ + clearTranslations: { parameters: { path: { projectId: number; @@ -11271,11 +15702,12 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["TagKeysRequest"]; + "application/json": components["schemas"]["ClearTranslationsRequest"]; }; }; }; - setTranslationState_2: { + /** Unlike the /v2/projects/{projectId}/import endpoint, imports the data in single request by provided files and parameters. This is useful for automated importing via API or CLI. */ + doImport: { parameters: { path: { projectId: number; @@ -11283,11 +15715,7 @@ export interface operations { }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["BatchJobModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -11323,12 +15751,20 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SetTranslationsStateStateRequest"]; + "multipart/form-data": { + files: string[]; + params: components["schemas"]["SingleStepImportRequest"]; + }; }; }; }; - setKeysNamespace: { + /** Prepares provided files to import. */ + addFiles: { parameters: { + query: { + /** When importing files in structured formats (e.g., JSON, YAML), this field defines the delimiter which will be used in names of imported keys. */ + structureDelimiter?: string; + }; path: { projectId: number; }; @@ -11337,7 +15773,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["BatchJobModel"]; + "application/json": components["schemas"]["ImportAddFilesResultModel"]; }; }; /** Bad Request */ @@ -11375,12 +15811,14 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SetKeysNamespaceRequest"]; + "multipart/form-data": { + files: string[]; + }; }; }; }; - /** Pre-translate provided keys to provided languages by TM. */ - translate_1: { + /** Deletes prepared import data. */ + cancelImport: { parameters: { path: { projectId: number; @@ -11388,11 +15826,7 @@ export interface operations { }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["BatchJobModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -11426,15 +15860,106 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["PreTranslationByTmRequest"]; + }; + export: { + parameters: { + query: { + /** + * Languages to be contained in export. + * + * If null, all languages are exported + */ + languages?: string[]; + /** Format to export to */ + format?: + | "JSON" + | "JSON_TOLGEE" + | "XLIFF" + | "PO" + | "APPLE_STRINGS_STRINGSDICT" + | "APPLE_XLIFF" + | "ANDROID_XML" + | "FLUTTER_ARB" + | "PROPERTIES" + | "YAML_RUBY" + | "YAML"; + /** + * Delimiter to structure file content. + * + * e.g. For key "home.header.title" would result in {"home": {"header": "title": {"Hello"}}} structure. + * + * When null, resulting file won't be structured. Works only for generic structured formats (e.g. JSON, YAML), + * specific formats like `YAML_RUBY` don't honor this parameter. + */ + structureDelimiter?: string; + /** Filter key IDs to be contained in export */ + filterKeyId?: number[]; + /** Filter key IDs not to be contained in export */ + filterKeyIdNot?: number[]; + /** + * Filter keys tagged by. + * + * This filter works the same as `filterTagIn` but in this cases it accepts single tag only. + */ + filterTag?: string; + /** Filter keys tagged by one of provided tags */ + filterTagIn?: string[]; + /** Filter keys not tagged by one of provided tags */ + filterTagNotIn?: string[]; + /** Filter keys with prefix */ + filterKeyPrefix?: string; + /** Filter translations with state. By default, all states except untranslated is exported. */ + filterState?: ( + | "UNTRANSLATED" + | "TRANSLATED" + | "REVIEWED" + | "DISABLED" + )[]; + /** Filter translations with namespace. By default, all namespaces everything are exported. To export default namespace, use empty string. */ + filterNamespace?: string[]; + /** + * If false, it doesn't return zip of files, but it returns single file. + * + * This is possible only when single language is exported. Otherwise it returns "400 - Bad Request" response. + */ + zip?: boolean; + /** + * Message format to be used for export. + * + * e.g. PHP_PO: Hello %s, ICU: Hello {name}. + * + * This property is honored only for generic formats like JSON or YAML. + * For specific formats like `YAML_RUBY` it's ignored. + */ + messageFormat?: + | "C_SPRINTF" + | "PHP_SPRINTF" + | "JAVA_STRING_FORMAT" + | "APPLE_SPRINTF" + | "RUBY_SPRINTF" + | "ICU"; + /** + * This is a template that defines the structure of the resulting .zip file content. + * + * The template is a string that can contain the following placeholders: {namespace}, {languageTag}, + * {androidLanguageTag}, {snakeLanguageTag}, {extension}. + * + * For example, when exporting to JSON with the template `{namespace}/{languageTag}.{extension}`, + * the English translations of the `home` namespace will be stored in `home/en.json`. + * + * The `{snakeLanguageTag}` placeholder is the same as `{languageTag}` but in snake case. (e.g., en_US). + * + * The Android specific `{androidLanguageTag}` placeholder is the same as `{languageTag}` + * but in Android format. (e.g., en-rUS) + */ + fileStructureTemplate?: string; + /** + * If true, for structured formats (like JSON) arrays are supported. + * + * e.g. Key hello[0] will be exported as {"hello": ["..."]} + */ + supportArrays?: boolean; }; - }; - }; - /** Translate provided keys to provided languages through primary MT provider. */ - machineTranslation: { - parameters: { path: { projectId: number; }; @@ -11443,7 +15968,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["BatchJobModel"]; + "application/json": components["schemas"]["StreamingResponseBody"]; }; }; /** Bad Request */ @@ -11479,13 +16004,9 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["MachineTranslationRequest"]; - }; - }; }; - deleteKeys: { + /** Exports data (post). Useful when exceeding allowed URL size. */ + exportPost: { parameters: { path: { projectId: number; @@ -11495,7 +16016,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["BatchJobModel"]; + "application/json": components["schemas"]["StreamingResponseBody"]; }; }; /** Bad Request */ @@ -11533,12 +16054,12 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["DeleteKeysRequest"]; + "application/json": components["schemas"]["ExportParams"]; }; }; }; - /** Copy translation values from one language to other languages. */ - copyTranslations: { + /** Stores a bigMeta for a project */ + store_2: { parameters: { path: { projectId: number; @@ -11546,11 +16067,7 @@ export interface operations { }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["BatchJobModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -11586,73 +16103,33 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CopyTranslationRequest"]; + "application/json": components["schemas"]["BigMetaDto"]; }; }; }; - /** Clear translation values for provided keys in selected languages. */ - clearTranslations: { + /** Returns translation comments of translation */ + getAll_5: { parameters: { path: { + translationId: number; projectId: number; }; + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["BatchJobModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Unauthorized */ - 401: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Forbidden */ - 403: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Not Found */ - 404: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; + "application/json": components["schemas"]["PagedModelTranslationCommentModel"]; }; }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ClearTranslationsRequest"]; - }; - }; - }; - /** Unlike the /v2/projects/{projectId}/import endpoint, imports the data in single request by provided files and parameters. This is useful for automated importing via API or CLI. */ - doImport: { - parameters: { - path: { - projectId: number; - }; - }; - responses: { - /** OK */ - 200: unknown; /** Bad Request */ 400: { content: { @@ -11686,31 +16163,19 @@ export interface operations { }; }; }; - requestBody: { - content: { - "multipart/form-data": { - files: string[]; - params: components["schemas"]["SingleStepImportRequest"]; - }; - }; - }; }; - /** Prepares provided files to import. */ - addFiles: { + create_7: { parameters: { - query: { - /** When importing files in structured formats (e.g., JSON, YAML), this field defines the delimiter which will be used in names of imported keys. */ - structureDelimiter?: string; - }; path: { + translationId: number; projectId: number; }; }; responses: { - /** OK */ - 200: { + /** Created */ + 201: { content: { - "application/json": components["schemas"]["ImportAddFilesResultModel"]; + "*/*": components["schemas"]["TranslationCommentModel"]; }; }; /** Bad Request */ @@ -11748,22 +16213,24 @@ export interface operations { }; requestBody: { content: { - "multipart/form-data": { - files: string[]; - }; + "application/json": components["schemas"]["TranslationCommentDto"]; }; }; }; - /** Deletes prepared import data. */ - cancelImport: { + /** Creates a translation comment. Empty translation is stored, when not exists. */ + create_9: { parameters: { path: { projectId: number; }; }; responses: { - /** OK */ - 200: unknown; + /** Created */ + 201: { + content: { + "*/*": components["schemas"]["TranslationWithCommentModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -11780,123 +16247,40 @@ export interface operations { | components["schemas"]["ErrorResponseBody"]; }; }; - /** Forbidden */ - 403: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Not Found */ - 404: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - }; - }; - export: { - parameters: { - query: { - /** - * Languages to be contained in export. - * - * If null, all languages are exported - */ - languages?: string[]; - /** Format to export to */ - format?: - | "JSON" - | "JSON_TOLGEE" - | "XLIFF" - | "PO" - | "APPLE_STRINGS_STRINGSDICT" - | "APPLE_XLIFF" - | "ANDROID_XML" - | "FLUTTER_ARB" - | "PROPERTIES" - | "YAML_RUBY" - | "YAML"; - /** - * Delimiter to structure file content. - * - * e.g. For key "home.header.title" would result in {"home": {"header": "title": {"Hello"}}} structure. - * - * When null, resulting file won't be structured. Works only for generic structured formats (e.g. JSON, YAML), - * specific formats like `YAML_RUBY` don't honor this parameter. - */ - structureDelimiter?: string; - /** Filter key IDs to be contained in export */ - filterKeyId?: number[]; - /** Filter key IDs not to be contained in export */ - filterKeyIdNot?: number[]; - /** - * Filter keys tagged by. - * - * This filter works the same as `filterTagIn` but in this cases it accepts single tag only. - */ - filterTag?: string; - /** Filter keys tagged by one of provided tags */ - filterTagIn?: string[]; - /** Filter keys not tagged by one of provided tags */ - filterTagNotIn?: string[]; - /** Filter keys with prefix */ - filterKeyPrefix?: string; - /** Filter translations with state. By default, all states except untranslated is exported. */ - filterState?: ( - | "UNTRANSLATED" - | "TRANSLATED" - | "REVIEWED" - | "DISABLED" - )[]; - /** Filter translations with namespace. By default, all namespaces everything are exported. To export default namespace, use empty string. */ - filterNamespace?: string[]; - /** - * If false, it doesn't return zip of files, but it returns single file. - * - * This is possible only when single language is exported. Otherwise it returns "400 - Bad Request" response. - */ - zip?: boolean; - /** - * Message format to be used for export. - * - * e.g. PHP_PO: Hello %s, ICU: Hello {name}. - * - * This property is honored only for generic formats like JSON or YAML. - * For specific formats like `YAML_RUBY` it's ignored. - */ - messageFormat?: - | "C_SPRINTF" - | "PHP_SPRINTF" - | "JAVA_STRING_FORMAT" - | "APPLE_SPRINTF" - | "RUBY_SPRINTF" - | "ICU"; - /** - * This is a template that defines the structure of the resulting .zip file content. - * - * The template is a string that can contain the following placeholders: {namespace}, {languageTag}, - * {androidLanguageTag}, {snakeLanguageTag}, {extension}. - * - * For example, when exporting to JSON with the template `{namespace}/{languageTag}.{extension}`, - * the English translations of the `home` namespace will be stored in `home/en.json`. - * - * The `{snakeLanguageTag}` placeholder is the same as `{languageTag}` but in snake case. (e.g., en_US). - * - * The Android specific `{androidLanguageTag}` placeholder is the same as `{languageTag}` - * but in Android format. (e.g., en-rUS) - */ - fileStructureTemplate?: string; - /** - * If true, for structured formats (like JSON) arrays are supported. - * - * e.g. Key hello[0] will be exported as {"hello": ["..."]} - */ - supportArrays?: boolean; - }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TranslationCommentWithLangKeyDto"]; + }; + }; + }; + /** Suggests machine translations from translation memory. The result is always sorted by similarity, so sorting is not supported. */ + suggestTranslationMemory: { + parameters: { + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; path: { projectId: number; }; @@ -11905,7 +16289,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["StreamingResponseBody"]; + "application/json": components["schemas"]["PagedModelTranslationMemoryItemModel"]; }; }; /** Bad Request */ @@ -11941,9 +16325,14 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["SuggestRequestDto"]; + }; + }; }; - /** Exports data (post). Useful when exceeding allowed URL size. */ - exportPost: { + /** Suggests machine translations from enabled services. The results are streamed to the output in ndjson format. If an error occurs when for any service provider used, the error information is returned as a part of the result item, while the response has 200 status code. */ + suggestMachineTranslationsStreaming: { parameters: { path: { projectId: number; @@ -11953,7 +16342,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["StreamingResponseBody"]; + "application/x-ndjson": components["schemas"]["StreamingResponseBody"]; }; }; /** Bad Request */ @@ -11991,12 +16380,12 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ExportParams"]; + "application/json": components["schemas"]["SuggestRequestDto"]; }; }; }; - /** Stores a bigMeta for a project */ - store_2: { + /** Suggests machine translations from enabled services */ + suggestMachineTranslations: { parameters: { path: { projectId: number; @@ -12004,7 +16393,11 @@ export interface operations { }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["SuggestResultModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -12040,15 +16433,13 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["BigMetaDto"]; + "application/json": components["schemas"]["SuggestRequestDto"]; }; }; }; - /** Returns translation comments of translation */ - getAll_5: { + getAll_7: { parameters: { path: { - translationId: number; projectId: number; }; query: { @@ -12064,7 +16455,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["PagedModelTranslationCommentModel"]; + "application/json": components["schemas"]["PagedModelLanguageModel"]; }; }; /** Bad Request */ @@ -12101,18 +16492,17 @@ export interface operations { }; }; }; - create_7: { + createLanguage: { parameters: { path: { - translationId: number; projectId: number; }; }; responses: { - /** Created */ - 201: { + /** OK */ + 200: { content: { - "*/*": components["schemas"]["TranslationCommentModel"]; + "application/json": components["schemas"]["LanguageModel"]; }; }; /** Bad Request */ @@ -12150,22 +16540,22 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["TranslationCommentDto"]; + "application/json": components["schemas"]["LanguageRequest"]; }; }; }; - /** Creates a translation comment. Empty translation is stored, when not exists. */ - create_9: { + getKeyScreenshots_1: { parameters: { path: { + keyId: number; projectId: number; }; }; responses: { - /** Created */ - 201: { + /** OK */ + 200: { content: { - "*/*": components["schemas"]["TranslationWithCommentModel"]; + "application/json": components["schemas"]["CollectionModelScreenshotModel"]; }; }; /** Bad Request */ @@ -12201,32 +16591,19 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["TranslationCommentWithLangKeyDto"]; - }; - }; }; - /** Suggests machine translations from translation memory. The result is always sorted by similarity, so sorting is not supported. */ - suggestTranslationMemory: { + uploadScreenshot_1: { parameters: { - query: { - /** Zero-based page index (0..N) */ - page?: number; - /** The size of the page to be returned */ - size?: number; - /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ - sort?: string[]; - }; path: { + keyId: number; projectId: number; }; }; responses: { - /** OK */ - 200: { + /** Created */ + 201: { content: { - "application/json": components["schemas"]["PagedModelTranslationMemoryItemModel"]; + "*/*": components["schemas"]["ScreenshotModel"]; }; }; /** Bad Request */ @@ -12264,22 +16641,30 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SuggestRequestDto"]; + "multipart/form-data": { + /** Format: binary */ + screenshot: string; + info?: components["schemas"]["ScreenshotInfoDto"]; + }; }; }; }; - /** Suggests machine translations from enabled services. The results are streamed to the output in ndjson format. If an error occurs when for any service provider used, the error information is returned as a part of the result item, while the response has 200 status code. */ - suggestMachineTranslationsStreaming: { + getAll_9: { parameters: { - path: { - projectId: number; + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; }; }; responses: { /** OK */ 200: { content: { - "application/x-ndjson": components["schemas"]["StreamingResponseBody"]; + "application/json": components["schemas"]["PagedModelPatModel"]; }; }; /** Bad Request */ @@ -12315,24 +16700,13 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["SuggestRequestDto"]; - }; - }; }; - /** Suggests machine translations from enabled services */ - suggestMachineTranslations: { - parameters: { - path: { - projectId: number; - }; - }; + create_11: { responses: { - /** OK */ - 200: { + /** Created */ + 201: { content: { - "application/json": components["schemas"]["SuggestResultModel"]; + "*/*": components["schemas"]["RevealedPatModel"]; }; }; /** Bad Request */ @@ -12370,15 +16744,13 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SuggestRequestDto"]; + "application/json": components["schemas"]["CreatePatDto"]; }; }; }; - getAll_7: { + /** Returns all organizations, which is current user allowed to view */ + getAll_10: { parameters: { - path: { - projectId: number; - }; query: { /** Zero-based page index (0..N) */ page?: number; @@ -12386,13 +16758,15 @@ export interface operations { size?: number; /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ sort?: string[]; + filterCurrentUserOwner?: boolean; + search?: string; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["PagedModelLanguageModel"]; + "application/hal+json": components["schemas"]["PagedModelOrganizationModel"]; }; }; /** Bad Request */ @@ -12429,17 +16803,12 @@ export interface operations { }; }; }; - createLanguage: { - parameters: { - path: { - projectId: number; - }; - }; + create_12: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["LanguageModel"]; + "application/json": components["schemas"]["OrganizationModel"]; }; }; /** Bad Request */ @@ -12477,24 +16846,23 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["LanguageRequest"]; + "application/json": components["schemas"]["OrganizationDto"]; }; }; }; - getKeyScreenshots_1: { + /** + * This endpoint allows the owner of an organization to connect a Slack workspace to their organization. + * Checks if the Slack integration feature is enabled for the organization and proceeds with the connection. + */ + connectWorkspace: { parameters: { path: { - keyId: number; - projectId: number; + organizationId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["CollectionModelScreenshotModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -12528,21 +16896,16 @@ export interface operations { }; }; }; - }; - uploadScreenshot_1: { - parameters: { - path: { - keyId: number; - projectId: number; + requestBody: { + content: { + "application/json": components["schemas"]["ConnectToSlackDto"]; }; }; + }; + unmarkNotificationsAsDone: { responses: { - /** Created */ - 201: { - content: { - "*/*": components["schemas"]["ScreenshotModel"]; - }; - }; + /** No Content */ + 204: never; /** Bad Request */ 400: { content: { @@ -12578,32 +16941,61 @@ export interface operations { }; requestBody: { content: { - "multipart/form-data": { - /** Format: binary */ - screenshot: string; - info?: components["schemas"]["ScreenshotInfoDto"]; - }; + "application/json": number[]; }; }; }; - getAll_9: { + subscribeToProject: { parameters: { - query: { - /** Zero-based page index (0..N) */ - page?: number; - /** The size of the page to be returned */ - size?: number; - /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ - sort?: string[]; + path: { + id: number; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["PagedModelPatModel"]; + "application/json": string; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; }; }; + }; + }; + markNotificationsAsUnread: { + responses: { + /** No Content */ + 204: never; /** Bad Request */ 400: { content: { @@ -12637,15 +17029,16 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": number[]; + }; + }; }; - create_11: { + markNotificationsAsRead: { responses: { - /** Created */ - 201: { - content: { - "*/*": components["schemas"]["RevealedPatModel"]; - }; - }; + /** No Content */ + 204: never; /** Bad Request */ 400: { content: { @@ -12681,31 +17074,14 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreatePatDto"]; + "application/json": number[]; }; }; }; - /** Returns all organizations, which is current user allowed to view */ - getAll_10: { - parameters: { - query: { - /** Zero-based page index (0..N) */ - page?: number; - /** The size of the page to be returned */ - size?: number; - /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ - sort?: string[]; - filterCurrentUserOwner?: boolean; - search?: string; - }; - }; + markAllNotificationsAsRead: { responses: { - /** OK */ - 200: { - content: { - "application/hal+json": components["schemas"]["PagedModelOrganizationModel"]; - }; - }; + /** No Content */ + 204: never; /** Bad Request */ 400: { content: { @@ -12740,14 +17116,10 @@ export interface operations { }; }; }; - create_12: { + markNotificationsAsDone: { responses: { - /** OK */ - 200: { - content: { - "application/json": components["schemas"]["OrganizationModel"]; - }; - }; + /** No Content */ + 204: never; /** Bad Request */ 400: { content: { @@ -12783,23 +17155,14 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["OrganizationDto"]; + "application/json": number[]; }; }; }; - /** - * This endpoint allows the owner of an organization to connect a Slack workspace to their organization. - * Checks if the Slack integration feature is enabled for the organization and proceeds with the connection. - */ - connectWorkspace: { - parameters: { - path: { - organizationId: number; - }; - }; + markAllNotificationsAsDone: { responses: { - /** OK */ - 200: unknown; + /** No Content */ + 204: never; /** Bad Request */ 400: { content: { @@ -12833,11 +17196,6 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["ConnectToSlackDto"]; - }; - }; }; upload: { responses: { @@ -14299,6 +18657,159 @@ export interface operations { }; }; }; + /** This endpoints returns the activity grouped by time windows so it's easier to read on the frontend. */ + getActivityGroups: { + parameters: { + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + filterType?: + | "SET_TRANSLATION_STATE" + | "REVIEW" + | "SET_BASE_TRANSLATION" + | "SET_TRANSLATIONS" + | "DISMISS_AUTO_TRANSLATED_STATE" + | "SET_OUTDATED_FLAG" + | "ADD_TRANSLATION_COMMENT" + | "DELETE_TRANSLATION_COMMENT" + | "EDIT_TRANSLATION_COMMENT" + | "SET_TRANSLATION_COMMENT_STATE" + | "DELETE_SCREENSHOT" + | "ADD_SCREENSHOT" + | "EDIT_KEY_TAGS" + | "EDIT_KEY_NAME" + | "DELETE_KEY" + | "CREATE_KEY" + | "IMPORT" + | "CREATE_LANGUAGE" + | "EDIT_LANGUAGE" + | "DELETE_LANGUAGE" + | "CREATE_PROJECT" + | "EDIT_PROJECT" + | "NAMESPACE_EDIT" + | "BATCH_PRE_TRANSLATE_BY_TM" + | "BATCH_MACHINE_TRANSLATE" + | "AUTO_TRANSLATE" + | "BATCH_CLEAR_TRANSLATIONS" + | "BATCH_COPY_TRANSLATIONS" + | "BATCH_SET_TRANSLATION_STATE" + | "CONTENT_DELIVERY_CONFIG_CREATE" + | "CONTENT_DELIVERY_CONFIG_UPDATE" + | "CONTENT_DELIVERY_CONFIG_DELETE" + | "CONTENT_STORAGE_CREATE" + | "CONTENT_STORAGE_UPDATE" + | "CONTENT_STORAGE_DELETE" + | "WEBHOOK_CONFIG_CREATE" + | "WEBHOOK_CONFIG_UPDATE" + | "WEBHOOK_CONFIG_DELETE"; + filterLanguageIdIn?: number[]; + filterAuthorUserIdIn?: number[]; + }; + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/hal+json": components["schemas"]["PagedModelActivityGroupModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + getCreateKeyItems: { + parameters: { + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; + path: { + groupId: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/hal+json": components["schemas"]["PagedModelCreateKeyGroupItemModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; getActivity: { parameters: { query: { @@ -16346,6 +20857,103 @@ export interface operations { }; }; }; + getNotifications: { + parameters: { + query: { + status?: ("UNREAD" | "READ" | "DONE")[]; + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["UserNotificationModel"][]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + getAllPreferences: { + responses: { + /** OK */ + 200: { + content: { + "application/json": { + [key: string]: components["schemas"]["NotificationPreferencesDto"]; + }; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; acceptInvitation: { parameters: { path: { diff --git a/webapp/src/views/notifications/NotificationsRouter.tsx b/webapp/src/views/notifications/NotificationsRouter.tsx new file mode 100644 index 0000000000..8ebc63e082 --- /dev/null +++ b/webapp/src/views/notifications/NotificationsRouter.tsx @@ -0,0 +1,64 @@ +/** + * Copyright (C) 2024 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Switch } from 'react-router-dom'; +import { DashboardPage } from 'tg.component/layout/DashboardPage'; +import { PrivateRoute } from 'tg.component/common/PrivateRoute'; +import { LINKS } from 'tg.constants/links'; +import { NotificationsView } from 'tg.views/notifications/NotificationsView'; +import { useTranslate } from '@tolgee/react'; + +export const NotificationsRouter: React.FC = () => { + const { t } = useTranslate(); + + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/webapp/src/views/notifications/NotificationsView.tsx b/webapp/src/views/notifications/NotificationsView.tsx new file mode 100644 index 0000000000..061f146f47 --- /dev/null +++ b/webapp/src/views/notifications/NotificationsView.tsx @@ -0,0 +1,63 @@ +/** + * Copyright (C) 2024 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LINKS } from 'tg.constants/links'; + +import { useTranslate } from '@tolgee/react'; +import { BaseSettingsView } from 'tg.component/layout/BaseSettingsView/BaseSettingsView'; +import { NavigationItem } from 'tg.component/navigation/Navigation'; + +type Props = { + unread?: boolean; + read?: boolean; + done?: boolean; + navigation: NavigationItem; +}; + +export const NotificationsView: React.FC = ({ + navigation, + unread, + read, + done, +}) => { + const { t } = useTranslate(); + + return ( + +

test

+
+ ); +}; diff --git a/webapp/src/views/projects/ActivityView.tsx b/webapp/src/views/projects/ActivityView.tsx new file mode 100644 index 0000000000..a023c6dd0a --- /dev/null +++ b/webapp/src/views/projects/ActivityView.tsx @@ -0,0 +1,31 @@ +import { useTranslate } from '@tolgee/react'; +import { BaseProjectView } from './BaseProjectView'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { useProject } from 'tg.hooks/useProject'; +import { PaginatedHateoasList } from 'tg.component/common/list/PaginatedHateoasList'; +import { ActivityGroupItem } from 'tg.component/activity/groups/ActivityGroupItem'; +import { ProjectLanguagesProvider } from 'tg.hooks/ProjectLanguagesProvider'; + +export const ActivityView = () => { + const { t } = useTranslate(); + + const project = useProject(); + + const groupsLoadable = useApiQuery({ + url: '/v2/projects/{projectId}/activity/groups', + method: 'get', + path: { projectId: project.id }, + query: {}, + }); + + return ( + + + } + /> + + + ); +}; diff --git a/webapp/src/views/projects/ProjectRouter.tsx b/webapp/src/views/projects/ProjectRouter.tsx index 9b39a5ba72..e47637e2bc 100644 --- a/webapp/src/views/projects/ProjectRouter.tsx +++ b/webapp/src/views/projects/ProjectRouter.tsx @@ -19,6 +19,7 @@ import { WebsocketPreview } from './WebsocketPreview'; import { DeveloperView } from './developer/DeveloperView'; import { HideObserver } from 'tg.component/layout/TopBar/HideObserver'; import { ActivityDetailRedirect } from 'tg.component/security/ActivityDetailRedirect'; +import { ActivityView } from './ActivityView'; const IntegrateView = React.lazy(() => import('tg.views/projects/integrate/IntegrateView').then((r) => ({ @@ -85,6 +86,9 @@ export const ProjectRouter = () => { + + + {/* Preview section... */} diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index ebbd50e0e7..2939619e54 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -2,7 +2,6 @@ import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; import viteTsconfigPaths from 'vite-tsconfig-paths'; import svgr from 'vite-plugin-svgr'; -import { nodePolyfills } from 'vite-plugin-node-polyfills'; import mdx from '@mdx-js/rollup'; import { viteStaticCopy } from 'vite-plugin-static-copy'; import { resolve } from 'path'; @@ -21,12 +20,16 @@ export default defineConfig(({ mode }) => { viteTsconfigPaths(), svgr(), mdx({ rehypePlugins: [rehypeHighlight] }), - nodePolyfills(), extractDataCy(), viteStaticCopy({ targets: [ { - src: resolve('node_modules/@tginternal/language-util/flags'), + src: resolve( + 'node_modules', + '@tginternal', + 'language-util', + 'flags' + ), dest: '', }, ], @@ -34,7 +37,7 @@ export default defineConfig(({ mode }) => { ], server: { // this ensures that the browser opens upon server start - open: true, + // open: true, // this sets a default port to 3000 port: Number(process.env.VITE_PORT) || 3000, },