Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
69e899e
feat: implement soft-delete for keys (move to trash)
bdshadow Feb 19, 2026
1a8905f
test: add soft-delete edge case tests for branching, namespaces, and …
bdshadow Feb 19, 2026
52c03be
feat: add trash UI and refactor key search for soft-deleted keys
bdshadow Feb 23, 2026
b18662d
fix: handle soft-deleted keys in branching operations
bdshadow Feb 23, 2026
f9c7f9e
feat: add red dot badge to trash icon when keys are in trash
bdshadow Feb 23, 2026
42123c8
feat: add trash translation keys to en.json and remove inline defaults
bdshadow Feb 23, 2026
5aaaffa
feat: add batch restore and hard-delete operations for trash
bdshadow Feb 23, 2026
4bb3134
fix: invalidate trash count cache after batch operations
bdshadow Feb 24, 2026
8a49aef
feat: add screenshots to trash view with proper sizing and text wrapping
bdshadow Feb 24, 2026
b87d83f
feat: include isPlural in trash API for proper plural rendering
bdshadow Feb 24, 2026
4f4d479
fix: add display names for restore and hard-delete batch operations
bdshadow Feb 24, 2026
ab1392c
fix: make TrashedKeyWithTranslationsModelAssembler thread-safe
bdshadow Feb 24, 2026
32bf473
fix: add activity logging for permanent key deletion
bdshadow Feb 24, 2026
6832222
feat: add activity log labels for trash operations
bdshadow Feb 24, 2026
10a20d9
fix: scope restoreKeys to project and include key name in conflict error
bdshadow Feb 24, 2026
1b4986a
fix: include deletedAt in Key.isModified for branch revision tracking
bdshadow Feb 24, 2026
66433e6
fix: add data-cy and aria-label to TrashBanner close button
bdshadow Feb 24, 2026
b16ee87
refactor: remove @ts-ignore and as-any casts now that schema includes…
bdshadow Feb 24, 2026
31aaea3
perf: fix N+1 queries, unbounded purge, missing index, and redundant …
bdshadow Feb 24, 2026
65a9814
chore: prettify UI
bdshadow Feb 24, 2026
501869a
fix: update websocket test and namespace endpoint for soft-delete
bdshadow Feb 24, 2026
e144f99
fix: mark key as deleted in UI when soft-delete websocket event arrives
bdshadow Feb 24, 2026
ba1b8a6
chore: prettify UI
bdshadow Feb 24, 2026
36c6803
fix: exclude soft-deleted keys from import resolution and translation…
bdshadow Feb 25, 2026
85651cb
fix: prevent duplicate ORDER BY in trash search query
bdshadow Feb 25, 2026
97f802e
test: add trash search and list tests
bdshadow Feb 25, 2026
3f84b36
fix: equalize spacing between sort and trash icons
bdshadow Feb 25, 2026
e80b6d6
fix: translations according to design
bdshadow Feb 25, 2026
cf7558b
fix: invalidate trash count after batch delete finishes
bdshadow Feb 25, 2026
22155f5
feat: show deleted-by user avatar in trash
bdshadow Feb 25, 2026
edf9a3c
fix: align trash column content to top
bdshadow Feb 25, 2026
75f9f0c
fix: improve trash view layout alignment and banner spacing
bdshadow Feb 25, 2026
0441e2d
feat: reuse translation view infrastructure for trash filtering
bdshadow Feb 25, 2026
d3e1d2b
fix: resolve NPE in softDeleteMultiple default parameter on CGLIB proxy
bdshadow Feb 26, 2026
acd9245
fix: trash table header styling and full-width layout
bdshadow Feb 26, 2026
a53f9a8
fix: narrow key column to snug trashed column to the left
bdshadow Feb 26, 2026
b4d8d80
refactor: extract shared KeyCellContent and TranslationCellReadOnly c…
bdshadow Feb 26, 2026
a2ad2fd
fix: hard-delete soft-deleted keys before merge addition to prevent c…
bdshadow Feb 27, 2026
485f841
fix: filter trash count by branch and exclude soft-deleted keys from …
bdshadow Feb 27, 2026
b2bc1b2
fix: propagate KEY_EXISTS error to UI during batch restore keys
bdshadow Feb 27, 2026
e31ab90
feat: add branch selector to trash view
bdshadow Feb 27, 2026
16f2444
fix: preserve current branch when navigating to trash view
bdshadow Feb 27, 2026
2874665
fix: hide broken "show failed keys" link for batch restore jobs
bdshadow Feb 27, 2026
07dff04
test: add e2e test for trashing duplicate key names
bdshadow Feb 28, 2026
ace53c0
feat: improve trash view UX
bdshadow Mar 2, 2026
12cc271
feat: add sort menu to trash view
bdshadow Mar 2, 2026
ff75970
chore: ktlint
bdshadow Mar 2, 2026
d7b1749
chore: regenerate API schema and remove unnecessary type casts
bdshadow Mar 2, 2026
39f1d79
chore: ts pretify
bdshadow Mar 2, 2026
e72c4c5
refactor: extract UserSearchSelect as non-EE component for trash filter
bdshadow Mar 2, 2026
64bad1e
fix: use static translation keys in TrashRow for Tolgee extractor
bdshadow Mar 2, 2026
ed163e2
fix: prefix unused key2Id variable in e2e test
bdshadow Mar 2, 2026
a60e8dc
fix: preserve soft-deleted keys during branch merge addition
bdshadow Mar 2, 2026
8c4de4e
test: add e2e test for soft-deleted key exclusion from tasks
bdshadow Mar 2, 2026
f0429a4
fix: address CodeRabbit review findings
bdshadow Mar 3, 2026
bc9fe32
test: add tests for trash purge, export exclusion, permissions, and b…
bdshadow Mar 3, 2026
80250cf
fix: add deterministic ORDER BY to findSoftDeletedByProjectId query
bdshadow Mar 3, 2026
d257ef1
fix: replace super-auth user search with lightweight deleters endpoin…
bdshadow Mar 3, 2026
01fc722
feat: add horizontal scroll arrows to trash view
bdshadow Mar 3, 2026
2eeefd5
feat: add trash navigation links in activity detail for soft-deleted …
bdshadow Mar 3, 2026
cfecab0
chore: revert en.json updates
bdshadow Mar 4, 2026
1ea817a
refactor: consolidate RETENTION_DAYS constant into KeyTrashPurgeSched…
bdshadow Mar 4, 2026
807d471
fix: refine generic type
bdshadow Mar 4, 2026
6cd0c45
refactor: extract KeyTrashService and make assembler a singleton comp…
bdshadow Mar 4, 2026
c6e2051
fix: replace any types with proper types in trash components
bdshadow Mar 4, 2026
034b99c
refactor: use invalidateUrlPrefix instead of refetch in TrashPage
bdshadow Mar 4, 2026
b1f56d0
refactor: move hard delete logic from controller to KeyService
bdshadow Mar 4, 2026
2ac8bc6
refactor: replace API calls with UI interactions in soft delete e2e t…
bdshadow Mar 4, 2026
c9019ce
refactor: use TestData pattern for soft delete e2e tests
bdshadow Mar 4, 2026
945fcc6
fix: resolve N+1 query in restoreKeys batch operation
bdshadow Mar 4, 2026
e10b92b
refactor: split TrashPage and TrashRow into smaller components
bdshadow Mar 4, 2026
7147d0c
fix: reuse deletedBy join between QueryBase and QueryGlobalFiltering
bdshadow Mar 4, 2026
f349eb7
fix: reuse branch join and fetch branch eagerly in restore query
bdshadow Mar 4, 2026
6123a4c
chore: pretify typescript
bdshadow Mar 4, 2026
bb6d524
refactor: use generated TrashedKeyWithTranslationsModel type
bdshadow Mar 4, 2026
8a01734
test: add e2e test for filtering trashed keys by deleter
bdshadow Mar 4, 2026
24ae990
chore: revert unintended en.json whitespace change
bdshadow Mar 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,17 @@ class NamespaceController(
fun getUsedNamespaces(): CollectionModel<UsedNamespaceModel> {
val namespaces =
namespaceService
.getAllInProject(projectHolder.project.id)
.getAllWithActiveKeysInProject(projectHolder.project.id)
.map { it.id as Long? to it.name as String? }
.toMutableList()
val isDefaultUsed = namespaceService.isDefaultUsed(projectHolder.project.id)
if (isDefaultUsed) {
namespaces.add(0, null to null)
}
val defaultNamespace = projectHolder.projectEntity.defaultNamespace
if (defaultNamespace != null && namespaces.none { it.first == defaultNamespace.id }) {
namespaces.add(defaultNamespace.id as Long? to defaultNamespace.name as String?)
}
return usedNamespaceModelAssembler.toCollectionModel(namespaces)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import io.tolgee.batch.data.BatchJobType
import io.tolgee.batch.request.ClearTranslationsRequest
import io.tolgee.batch.request.CopyTranslationRequest
import io.tolgee.batch.request.DeleteKeysRequest
import io.tolgee.batch.request.HardDeleteKeysRequest
import io.tolgee.batch.request.MachineTranslationRequest
import io.tolgee.batch.request.PreTranslationByTmRequest
import io.tolgee.batch.request.RestoreKeysRequest
import io.tolgee.batch.request.SetKeysNamespaceRequest
import io.tolgee.batch.request.SetTranslationsStateStateRequest
import io.tolgee.batch.request.TagKeysRequest
Expand Down Expand Up @@ -256,6 +258,42 @@ class StartBatchJobController(
).model
}

@PostMapping(value = ["/restore-keys"])
@Operation(summary = "Restore soft-deleted keys")
@RequiresProjectPermissions([Scope.KEYS_CREATE])
@AllowApiAccess
fun restoreKeys(
@Valid @RequestBody
data: RestoreKeysRequest,
): BatchJobModel {
securityService.checkSoftDeletedKeyIdsExistAndIsFromProject(data.keyIds, projectHolder.project.id)
return batchJobService
.startJob(
data,
projectHolder.projectEntity,
authenticationFacade.authenticatedUserEntity,
BatchJobType.RESTORE_KEYS,
).model
}

@PostMapping(value = ["/hard-delete-keys"])
@Operation(summary = "Permanently delete soft-deleted keys")
@RequiresProjectPermissions([Scope.KEYS_DELETE])
@AllowApiAccess
fun hardDeleteKeys(
@Valid @RequestBody
data: HardDeleteKeysRequest,
): BatchJobModel {
securityService.checkSoftDeletedKeyIdsExistAndIsFromProject(data.keyIds, projectHolder.project.id)
return batchJobService
.startJob(
data,
projectHolder.projectEntity,
authenticationFacade.authenticatedUserEntity,
BatchJobType.HARD_DELETE_KEYS,
).model
}

val BatchJob.model
get() = batchJobModelAssembler.toModel(batchJobService.getView(this))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ class KeyController(
@DeleteMapping(value = ["/{ids:[0-9,]+}"])
@Transactional
@Operation(summary = "Delete one or multiple keys")
@RequestActivity(ActivityType.KEY_DELETE)
@RequestActivity(ActivityType.KEY_SOFT_DELETE)
@RequiresProjectPermissions([Scope.KEYS_DELETE])
@AllowApiAccess
@OpenApiOrderExtension(5)
Expand All @@ -197,7 +197,7 @@ class KeyController(
checkBranchPermission()
}
}
keyService.deleteMultiple(ids)
keyService.softDeleteMultiple(ids)
}

@PutMapping(value = ["/{id}/complex-update"])
Expand Down Expand Up @@ -226,7 +226,7 @@ class KeyController(
"Delete one or multiple keys by their IDs in request body. Useful for larger requests" +
" esxceeding allowed URL length.",
)
@RequestActivity(ActivityType.KEY_DELETE)
@RequestActivity(ActivityType.KEY_SOFT_DELETE)
@RequiresProjectPermissions([Scope.KEYS_DELETE])
@AllowApiAccess
fun delete(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package io.tolgee.api.v2.controllers.keys

import io.swagger.v3.oas.annotations.Operation
import io.tolgee.activity.RequestActivity
import io.tolgee.activity.data.ActivityType
import io.tolgee.api.v2.controllers.IController
import io.tolgee.dtos.queryResults.KeyView
import io.tolgee.dtos.request.translation.TranslationFilters
import io.tolgee.hateoas.key.KeyModel
import io.tolgee.hateoas.key.KeyModelAssembler
import io.tolgee.hateoas.key.trash.TrashedKeyWithTranslationsModel
import io.tolgee.hateoas.key.trash.TrashedKeyWithTranslationsModelAssembler
import io.tolgee.hateoas.userAccount.SimpleUserAccountModel
import io.tolgee.hateoas.userAccount.SimpleUserAccountModelAssembler
import io.tolgee.model.enums.Scope
import io.tolgee.model.views.KeyWithTranslationsView
import io.tolgee.security.ProjectHolder
import io.tolgee.security.authentication.AllowApiAccess
import io.tolgee.security.authentication.AuthenticationFacade
import io.tolgee.security.authorization.RequiresProjectPermissions
import io.tolgee.service.key.KeyService
import io.tolgee.service.key.KeyTrashService
import org.springdoc.core.annotations.ParameterObject
import org.springframework.data.domain.Pageable
import org.springframework.data.web.PagedResourcesAssembler
import org.springframework.data.web.SortDefault
import org.springframework.hateoas.CollectionModel
import org.springframework.hateoas.PagedModel
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.CrossOrigin
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

@Suppress("MVCPathVariableInspection")
@RestController
@CrossOrigin(origins = ["*"])
@RequestMapping(
value = [
"/v2/projects/{projectId:[0-9]+}/keys/trash",
"/v2/projects/keys/trash",
],
)
@KeysDocsTag
class KeyTrashController(
private val keyService: KeyService,
private val keyTrashService: KeyTrashService,
private val projectHolder: ProjectHolder,
private val keyModelAssembler: KeyModelAssembler,
private val authenticationFacade: AuthenticationFacade,
private val trashedKeyWithTranslationsModelAssembler: TrashedKeyWithTranslationsModelAssembler,
private val simpleUserAccountModelAssembler: SimpleUserAccountModelAssembler,
@Suppress("SpringJavaInjectionPointsAutowiringInspection")
private val pagedResourcesAssembler: PagedResourcesAssembler<KeyWithTranslationsView>,
) : IController {
@GetMapping("")
@Operation(summary = "List trashed keys")
@RequiresProjectPermissions([Scope.KEYS_VIEW])
@AllowApiAccess
fun list(
@ParameterObject
@SortDefault("deletedAt")
pageable: Pageable,
@ParameterObject
@ModelAttribute
params: TranslationFilters,
): PagedModel<TrashedKeyWithTranslationsModel> {
val data =
keyTrashService.getTrashedKeysWithTranslations(
projectId = projectHolder.project.id,
userId = authenticationFacade.authenticatedUser.id,
pageable = pageable,
params = params,
)
return pagedResourcesAssembler.toModel(data, trashedKeyWithTranslationsModelAssembler)
}

@GetMapping("/deleters")
@Operation(summary = "List users who deleted keys")
@RequiresProjectPermissions([Scope.KEYS_VIEW])
@AllowApiAccess
fun listDeleters(
@RequestParam(required = false) branch: String?,
): CollectionModel<SimpleUserAccountModel> {
val users = keyService.findDistinctDeleters(projectHolder.project.id, branch)
return simpleUserAccountModelAssembler.toCollectionModel(users)
}

@PutMapping("/{keyId}/restore")
@Transactional
@Operation(summary = "Restore a trashed key")
@RequestActivity(ActivityType.KEY_RESTORE)
@RequiresProjectPermissions([Scope.KEYS_CREATE])
@AllowApiAccess
fun restore(
@PathVariable keyId: Long,
): KeyModel {
val key = keyService.restoreKey(projectHolder.project.id, keyId)
val view =
KeyView(
Comment thread
bdshadow marked this conversation as resolved.
key.id,
key.name,
key.namespace?.name,
key.keyMeta?.description,
key.keyMeta?.custom,
key.branch?.name,
)
return keyModelAssembler.toModel(view)
}

@DeleteMapping("/{keyId}")
@Transactional
@Operation(summary = "Permanently delete a trashed key")
@RequestActivity(ActivityType.KEY_HARD_DELETE)
@RequiresProjectPermissions([Scope.KEYS_DELETE])
@AllowApiAccess
fun permanentlyDelete(
@PathVariable keyId: Long,
) {
keyService.hardDeleteSingleTrashedKey(projectHolder.project.id, keyId)
}
Comment thread
bdshadow marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package io.tolgee.hateoas.key.trash

import io.swagger.v3.oas.annotations.media.Schema
import io.tolgee.hateoas.userAccount.SimpleUserAccountModel
import org.springframework.hateoas.RepresentationModel
import org.springframework.hateoas.server.core.Relation
import java.io.Serializable
import java.util.Date

@Suppress("unused")
@Relation(collectionRelation = "keys", itemRelation = "key")
class TrashedKeyModel(
@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 = "When the key was deleted")
val deletedAt: Date,
@Schema(description = "When the key will be permanently deleted")
val permanentDeleteAt: Date,
@Schema(description = "User who deleted the key")
val deletedBy: SimpleUserAccountModel?,
) : RepresentationModel<TrashedKeyModel>(),
Serializable
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.tolgee.hateoas.key.trash

import io.tolgee.api.v2.controllers.keys.KeyTrashController
import io.tolgee.hateoas.userAccount.SimpleUserAccountModel
import io.tolgee.service.AvatarService
import io.tolgee.service.key.KeySearchResultView
import io.tolgee.service.key.KeyTrashPurgeScheduler
import io.tolgee.util.addDays
import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport
import org.springframework.stereotype.Component

@Component
class TrashedKeyModelAssembler(
private val avatarService: AvatarService,
) : RepresentationModelAssemblerSupport<KeySearchResultView, TrashedKeyModel>(
KeyTrashController::class.java,
TrashedKeyModel::class.java,
) {
override fun toModel(entity: KeySearchResultView): TrashedKeyModel {
val deletedAt =
entity.deletedAt
?: throw IllegalStateException("Cannot assemble TrashedKeyModel: deletedAt is null for key ${entity.id}")
return TrashedKeyModel(
id = entity.id,
name = entity.name,
namespace = entity.namespace,
deletedAt = deletedAt,
permanentDeleteAt = deletedAt.addDays(KeyTrashPurgeScheduler.RETENTION_DAYS),
deletedBy = buildDeletedByModel(entity),
)
}

private fun buildDeletedByModel(entity: KeySearchResultView): SimpleUserAccountModel? {
return entity.deletedByUserId?.let { userId ->
SimpleUserAccountModel(
id = userId,
username = entity.deletedByUserUsername ?: "",
name = entity.deletedByUserName,
avatar = avatarService.getAvatarLinks(entity.deletedByUserAvatarHash),
deleted = entity.deletedByUserDeleted ?: false,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package io.tolgee.hateoas.key.trash

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.TranslationViewModel
import io.tolgee.hateoas.userAccount.SimpleUserAccountModel
import org.springframework.hateoas.RepresentationModel
import org.springframework.hateoas.server.core.Relation
import java.io.Serializable
import java.util.Date

@Suppress("unused")
@Relation(collectionRelation = "keys", itemRelation = "key")
class TrashedKeyWithTranslationsModel(
@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 = "When the key was deleted")
val deletedAt: Date,
@Schema(description = "When the key will be permanently deleted")
val permanentDeleteAt: Date,
@Schema(description = "Description of the key")
val description: String?,
@Schema(description = "Tags of key")
val tags: List<TagModel>,
@Schema(description = "Translations object keyed by language tag")
val translations: Map<String, TranslationViewModel>,
@Schema(description = "Screenshots of the key")
val screenshots: List<ScreenshotModel>,
@Schema(description = "Whether the key is plural")
val isPlural: Boolean,
@Schema(description = "User who deleted the key")
val deletedBy: SimpleUserAccountModel?,
) : RepresentationModel<TrashedKeyWithTranslationsModel>(),
Serializable
Loading
Loading