Skip to content

Unify mission move across backend CLI and UI with project migration support#2124

Open
Idate96 wants to merge 24 commits intodevfrom
lorenzo/server-side-migration
Open

Unify mission move across backend CLI and UI with project migration support#2124
Idate96 wants to merge 24 commits intodevfrom
lorenzo/server-side-migration

Conversation

@Idate96
Copy link
Contributor

@Idate96 Idate96 commented Mar 3, 2026

Use Case

We need to reorganize legacy datasets (for example consolidating old projects under moleworks) without client-side copy/re-upload jobs.

There is already a single-mission move action in the UI, but it was not consistently available across backend/CLI/API and did not support bulk moves. This PR unifies that path into one server-side move primitive and exposes it in CLI + frontend.

What This PR Changes

  • unify mission relocation on a single endpoint: POST /missions/move
  • replace single-mission payload with bulk payload:
    • missionUUIDs: string[]
    • targetProjectUUID: string
    • optional newName (allowed only for single mission)
  • remove separate mission-migration DTO/path and route all mission relocation through the same backend service
  • keep project-level migration (POST /projects/migrate) for full project-to-project consolidation flows

Backend

  • new DTOs: MoveMissionsDto, MoveMissionsResponseDto
  • new guard: MoveMissionsByBodyGuard with strict body validation and permission checks
  • mission controller now handles move via body DTO and returns typed move summary
  • mission service now has one moveMissions(...) implementation with:
    • non-empty + unique mission UUID validation
    • target-project existence checks
    • all-missions-must-exist checks
    • exact case-insensitive collision detection in target project
    • transactional all-or-nothing move and best-effort object-tag rollback on failure

CLI

  • replace mission migrate flow with mission move
  • support repeated --mission/-m for bulk move
  • strict per-token resolution (get_mission per selector) to prevent partial accidental moves
  • reject duplicate mission selections and invalid --new-name usage for multi-mission moves

Frontend

  • mission mutation updated to call POST /mission/move with bulk payload
  • move dialog now supports both single and multi-mission mode
  • missions explorer overlay now includes bulk Move
  • fixed dialog cancel behavior (Cancel no longer triggers onOk path)
  • fixed no-op disable logic when selected target project equals current project(s)

Validation

Passed locally:

  • pytest -q cli/tests/api/test_routes_migrate.py -p no:tests.backend_fixtures
  • npx -y pnpm@10.27.0 --filter ./backend test -- tests/services/mission-migrate.service.test.ts tests/services/project-migrate.service.test.ts
  • npx -y pnpm@10.27.0 --filter ./backend build
  • npx -y pnpm@10.27.0 --filter ./frontend build
  • npx -y prettier --check frontend/src/dialogs/modify-mission-location-dialog.vue frontend/src/pages/missions-explorer-page.vue
  • python -m py_compile cli/kleinkram/cli/_mission.py

Known local limitation:

  • backend/tests/auth/missions/mission-access.test.ts requires local Postgres; in this environment it fails with ECONNREFUSED 127.0.0.1:5432.

Reviewer Feedback Addressed

  • guard payload validation hardened (missionUUIDs array, UUID validation, single-mission newName restriction)
  • collision check fixed from wildcard-prone ILike to exact case-insensitive LOWER(name)=LOWER(:name)
  • CLI partial-match risk removed by strict per-selector mission resolution
  • move dialog cancel/no-op behavior corrected

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

26 files reviewed, 13 comments

Edit Code Review Agent Settings | Greptile

@Idate96 Idate96 requested a review from wp99cp March 3, 2026 12:19
@wp99cp wp99cp changed the base branch from main to dev March 3, 2026 12:25
@wp99cp
Copy link
Member

wp99cp commented Mar 3, 2026

I changed the base branch to dev, as all new PRs must go to dev to test it properly on our integration instance of Kleinkram. @Idate96 can you resolve the merge conflicts, then I will do a code review and include your PR in the next release. Hopefully by the end of the week.

@wp99cp
Copy link
Member

wp99cp commented Mar 3, 2026

Also, lets have a quick talk about this feature.

@Idate96 Idate96 force-pushed the lorenzo/server-side-migration branch from f8ddc4e to fcbbd69 Compare March 3, 2026 12:49
Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@Idate96
Copy link
Contributor Author

Idate96 commented Mar 3, 2026

Rebased onto dev and resolved merge conflicts.

I also aligned migration storage usage with the current DataStorageBucket wiring on dev, updated migration tests accordingly, and re-ran backend migration tests locally.

Could you take another look when you have a moment?

greptile-apps[bot]

This comment was marked as outdated.

greptile-apps[bot]

This comment was marked as outdated.

greptile-apps[bot]

This comment was marked as outdated.

@wp99cp
Copy link
Member

wp99cp commented Mar 3, 2026

@greptile

@greptile-apps
Copy link

greptile-apps bot commented Mar 3, 2026

Greptile Summary

This PR unifies mission relocation into a single POST /missions/move endpoint that replaces the old query-param-based single-mission move, adds bulk support, and introduces a complementary POST /projects/migrate endpoint for full project-to-project consolidation. The backend service layer now handles all-or-nothing transactional moves with exact case-insensitive name collision detection and best-effort storage tag rollback. The CLI gains mission move (bulk) and project migrate commands, the frontend move dialog now supports multi-mission selection with correct cancel behavior and some()-based no-op detection, and API-key clients no longer loop on 401 responses.

Key changes:

  • New MoveMissionsDto / MoveMissionsResponseDto DTOs; movedMissionUUIDs is now correctly forwarded in the controller response
  • MoveMissionsByBodyGuard validates array shape and newName single-mission restriction; permission checks parallelised with Promise.all
  • MigrateProjectByBodyGuard added with UUID validation before API-key rejection (consistent ordering with mission guard)
  • MissionService.moveMissions performs case-insensitive collision detection (against existing target missions and within the batch), updates inside a TypeORM transaction, then applies storage tags; failure rolls back tags best-effort
  • ProjectService.migrateProject follows the same transactional pattern with an exact LOWER(name) = LOWER(:name) archive-name uniqueness check
  • cli/kleinkram/api/client.py short-circuits 401 responses for API-key sessions to avoid spurious token refresh
  • Comprehensive unit tests added for both service methods

Minor issues noted:

  • _migrate_project in routes.py distinguishes source vs. target 404 errors by string-matching the backend error message — a fragile heuristic that could misroute if the message format changes
  • selectedMissions computed in the move dialog is annotated FlatMissionDto[] but can hold MissionWithFilesDto instances (missing filesCount/size); runtime behaviour is unaffected but the type annotation is misleading
  • MoveMissionsResponseDto.movedMissionCount is missing @Min(0), unlike the parallel MigrateProjectResponseDto

Confidence Score: 4/5

  • Safe to merge; the core move/migrate logic is correct and well-tested, and all prior review concerns have been addressed.
  • All critical issues from previous review rounds (parallel permission checks, isNoopMove semantics, movedMissionUUIDs forwarding, case-insensitive collision detection, ILike wildcard, cancel-button bug) have been resolved. The three remaining findings are P2 quality/consistency items that do not affect correctness or runtime safety. The transaction-plus-best-effort-rollback approach has dedicated tests covering the key failure scenarios. Score reflects a well-converged PR with only minor follow-up polish remaining.
  • No files require blocking attention. cli/kleinkram/api/routes.py (fragile 404 routing) and frontend/src/dialogs/modify-mission-location-dialog.vue (TypeScript type annotation) are worth a follow-up cleanup.

Important Files Changed

Filename Overview
backend/src/services/mission.service.ts Core moveMissions implementation added: bulk move with transactional DB update, case-insensitive collision detection, and best-effort storage tag rollback. Formatting-only changes elsewhere in the file.
backend/src/services/project.service.ts New migrateProject method migrates all missions from source to target project with exact case-insensitive collision detection, optional archive-rename, and best-effort storage tag rollback. IStorageBucket injected into constructor.
backend/src/endpoints/auth/guards/mission.guards.ts Replaced MoveMissionToProjectGuard (query-param based) with MoveMissionsByBodyGuard (body-based). Validates missionUUIDs array, targetProjectUUID UUID format, and newName single-mission restriction. Permission checks now run in parallel with Promise.all.
cli/kleinkram/api/routes.py New _move_missions and _migrate_project route functions added with HTTP error mapping. The 404 project-not-found discrimination uses fragile message string-matching (flagged).
frontend/src/dialogs/modify-mission-location-dialog.vue Dialog extended to support bulk mission moves. Cancel bug fixed (onDialogCancel instead of onDialogOK). isNoopMove uses some() to disable the OK button when any selected mission is already in the target, matching backend semantics. TypeScript type annotation mismatch noted for selectedMissions computed.
packages/api-dto/src/types/mission/move-missions.dto.ts New MoveMissionsDto and MoveMissionsResponseDto. The response DTO is missing @Min(0) on movedMissionCount, unlike the parallel MigrateProjectResponseDto (flagged).

Sequence Diagram

sequenceDiagram
    participant C as Client (UI / CLI)
    participant G as MoveMissionsByBodyGuard
    participant MS as MissionService
    participant DB as Database (Transaction)
    participant ST as Storage (addTags)

    C->>G: POST /missions/move {missionUUIDs[], targetProjectUUID, newName?}
    G->>G: Validate UUIDs, newName restriction
    G->>G: canAccessProject(target, CREATE)
    G->>G: Promise.all canAccessMission(each, DELETE)
    G-->>C: 400/401/403 if invalid/unauthorized
    G->>MS: moveSelectedMissions(dto)
    MS->>DB: BEGIN TRANSACTION
    DB->>DB: findOne(targetProject)
    DB->>DB: find(missions IN [...UUIDs])
    DB->>DB: Validate uniqueness & no-op collision
    DB->>DB: find(all missions in target project)
    DB->>DB: Case-insensitive collision check
    DB->>DB: missionRepository.update(project=target) × N
    DB->>ST: addTags(file, {projectUuid: target}) × N files
    ST-->>DB: tag success
    DB->>DB: COMMIT
    MS-->>C: {success, movedMissionCount, movedMissionUUIDs, targetProjectUUID}

    note over DB,ST: On any failure: DB rolls back + best-effort tag rollback to source project
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: cli/kleinkram/api/routes.py
Line: 4036-4040

Comment:
**Fragile 404 source/target discrimination via message parsing**

`_migrate_project` distinguishes a source-not-found 404 from a target-not-found 404 by searching for the target UUID string inside the backend error message. This ties the CLI's error routing to the exact wording of the backend's `NotFoundException` — if that message is ever changed or if both UUIDs somehow appear in the same message, callers would receive a misleading "source project not found" error.

A more robust pattern would be to key off a structured error field (e.g., `"field": "targetProjectUUID"`) or to emit the same generic `ProjectNotFound` with the raw backend detail forwarded as the message:

```python
if resp.status_code == 404:
    message = _extract_error_message(resp)
    raise ProjectNotFound(message)
```

This avoids the string-matching heuristic entirely while still surfacing the backend's explanation.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: frontend/src/dialogs/modify-mission-location-dialog.vue
Line: 80-94

Comment:
**`selectedMissions` computed has a TypeScript type annotation mismatch**

The computed is annotated as `FlatMissionDto[]`, but when `properties.mission` (typed `MissionWithFilesDto | FlatMissionDto`) is a `MissionWithFilesDto`, the returned `[properties.mission]` is not structurally assignable to `FlatMissionDto[]`. `MissionWithFilesDto` extends `MissionWithCreatorDto` and adds `files: FileDto[]`, but it lacks `filesCount` and `size` which `FlatMissionDto` requires.

At runtime this causes no problems — the dialog only accesses `.uuid`, `.name`, and `.project.uuid`, which both types share — but the annotation is misleading. You can narrow the type of the computed to include both forms:

```ts
const selectedMissions = computed<(MissionWithFilesDto | FlatMissionDto)[]>(() => {
    if (properties.missions && properties.missions.length > 0) {
        return properties.missions;
    }
    if (properties.mission) {
        return [properties.mission];
    }
    return [];
});
```

Downstream usages in this file (`mission.uuid`, `mission.name`, `mission.project.uuid`) will continue to resolve correctly because all three properties exist on `MissionWithCreatorDto`, the common base.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/api-dto/src/types/mission/move-missions.dto.ts
Line: 40-42

Comment:
**`movedMissionCount` missing `@Min(0)` validator**

`MigrateProjectResponseDto` (in the same PR) declares both `movedMissionCount` and `movedFileCount` with `@Min(0)`. `MoveMissionsResponseDto.movedMissionCount` omits this constraint, creating an inconsistency that could allow negative counts to pass deserialization in client code that validates responses.

```suggestion
    @ApiProperty()
    @IsInt()
    @Min(0)
    movedMissionCount!: number;
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (10): Last reviewed commit: "Merge origin/dev and address remaining P..." | Re-trigger Greptile

Comment on lines +208 to +225
if (apiKey) {
throw new UnauthorizedException('CLI Keys cannot migrate projects');
}

const body = request.body as ProjectBody | undefined;
const sourceProjectUUID = body?.sourceProjectUUID;
const targetProjectUUID = body?.targetProjectUUID;

if (!sourceProjectUUID || !targetProjectUUID) {
throw new BadRequestException(
'sourceProjectUUID and targetProjectUUID are required',
);
}
if (!isUUID(sourceProjectUUID) || !isUUID(targetProjectUUID)) {
throw new BadRequestException(
'sourceProjectUUID and targetProjectUUID must be valid UUIDs',
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent validation order between project and mission migration guards

MigrateProjectByBodyGuard checks API key authorization before validating the request body UUIDs:

if (apiKey) {
    throw new UnauthorizedException('CLI Keys cannot migrate projects');
}
// ... then body validation at lines 216-225

But MigrateMissionByBodyGuard (same PR) validates body first, then checks API key:

if (!missionUUID || !targetProjectUUID) {
    throw new BadRequestException('...');
}
if (!isUUID(missionUUID) || !isUUID(targetProjectUUID)) {
    throw new BadRequestException('...');
}
if (apiKey) {
    throw new UnauthorizedException('CLI Keys cannot move missions');
}

This causes an API-key caller with invalid/missing UUIDs to receive:

  • 400 Bad Request for mission migration (body validated first)
  • 401 Unauthorized for project migration (auth checked first)

For consistency and better security posture (avoid leaking "this endpoint blocks API keys" before validation), validate request body fields before auth shortcuts. Recommend reordering to match MigrateMissionByBodyGuard pattern: body validation → apiKey check → permission checks.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/src/endpoints/auth/guards/project.guards.ts
Line: 208-225

Comment:
**Inconsistent validation order between project and mission migration guards**

`MigrateProjectByBodyGuard` checks API key authorization before validating the request body UUIDs:
```ts
if (apiKey) {
    throw new UnauthorizedException('CLI Keys cannot migrate projects');
}
// ... then body validation at lines 216-225
```

But `MigrateMissionByBodyGuard` (same PR) validates body first, then checks API key:
```ts
if (!missionUUID || !targetProjectUUID) {
    throw new BadRequestException('...');
}
if (!isUUID(missionUUID) || !isUUID(targetProjectUUID)) {
    throw new BadRequestException('...');
}
if (apiKey) {
    throw new UnauthorizedException('CLI Keys cannot move missions');
}
```

This causes an API-key caller with invalid/missing UUIDs to receive:
- **400 Bad Request** for mission migration (body validated first)
- **401 Unauthorized** for project migration (auth checked first)

For consistency and better security posture (avoid leaking "this endpoint blocks API keys" before validation), validate request body fields before auth shortcuts. Recommend reordering to match `MigrateMissionByBodyGuard` pattern: body validation → apiKey check → permission checks.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — good catch on consistency. I aligned MigrateProjectByBodyGuard with the mission migration guard in 42dc87eb: body UUID presence/format is validated first, then API-key rejection is applied.

Comment on lines +573 to +574
if resp.status_code == 409:
raise ProjectValidationError(f"project migration conflict for source={source_project_id} target={target_project_id}")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

409 Conflict mapped to wrong error class

A 409 Conflict response (e.g., mission name collision, archive name taken) signals a resource state conflict, not a client-side validation error. However, it's mapped to ProjectValidationError:

if resp.status_code == 409:
    raise ProjectValidationError(f"project migration conflict for ...")

For comparison, the parallel mission migration handler (line 539) correctly uses a conflict-type error:

if resp.status_code == 409:
    raise MissionExists(f"target project already has a mission with ...")

Callers who catch ProjectValidationError will treat this as a bad request rather than recognizing it as a resource conflict. Consider raising a dedicated ProjectConflictError or reusing an existing conflict exception class so callers can surface clearer messaging (e.g., "migration failed because target project already has a mission named X").

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/kleinkram/api/routes.py
Line: 573-574

Comment:
**409 Conflict mapped to wrong error class**

A `409 Conflict` response (e.g., mission name collision, archive name taken) signals a **resource state conflict**, not a client-side validation error. However, it's mapped to `ProjectValidationError`:

```python
if resp.status_code == 409:
    raise ProjectValidationError(f"project migration conflict for ...")
```

For comparison, the parallel mission migration handler (line 539) correctly uses a conflict-type error:
```python
if resp.status_code == 409:
    raise MissionExists(f"target project already has a mission with ...")
```

Callers who catch `ProjectValidationError` will treat this as a bad request rather than recognizing it as a resource conflict. Consider raising a dedicated `ProjectConflictError` or reusing an existing conflict exception class so callers can surface clearer messaging (e.g., "migration failed because target project already has a mission named X").

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept this mapping as-is in this PR. Backend project migration currently uses 409 for multiple conflict causes (name collisions and concurrent-state conflicts), and the CLI currently models these as ProjectValidationError. I prefer to keep one stable mapping here and introduce a dedicated ProjectConflictError in a follow-up if we want finer-grained handling.

@greptile-apps
Copy link

greptile-apps bot commented Mar 3, 2026

Additional Comments (1)

backend/src/endpoints/auth/guards/mission.guards.ts
Admin bypass repositioned after UUID check in existing guards — silent behavior change

This PR repositioned the admin early-return in three existing guards:

  • CreateInMissionByBodyGuard (moved from line 124 to line 136)
  • WriteMissionByBodyGuard (moved from line 158 to line 170)
  • CanDeleteMissionGuard (moved from line 198 to line 215)

The admin check now runs after the missing-UUID validation. Previously, an ADMIN user could call these endpoints without supplying a missionUUID in the body and receive access (true). After this change, admins without missionUUID now receive denial (return false or BadRequestException).

If there are legitimate admin-only code paths (internal tooling, admin UI, existing tests) that call these endpoints without a body UUID, they will silently break after merge. This behavior change is not mentioned in the PR description.

Action: Please confirm this stricter behavior for admins is intentional. If so, consider documenting it with a test or a code comment explaining why admins must now provide missionUUID.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/src/endpoints/auth/guards/mission.guards.ts
Line: 122-144

Comment:
**Admin bypass repositioned after UUID check in existing guards — silent behavior change**

This PR repositioned the admin early-return in three existing guards:
- `CreateInMissionByBodyGuard` (moved from line 124 to line 136)
- `WriteMissionByBodyGuard` (moved from line 158 to line 170)
- `CanDeleteMissionGuard` (moved from line 198 to line 215)

The admin check now runs **after** the missing-UUID validation. Previously, an ADMIN user could call these endpoints without supplying a `missionUUID` in the body and receive access (`true`). After this change, admins without missionUUID now receive denial (`return false` or `BadRequestException`).

If there are legitimate admin-only code paths (internal tooling, admin UI, existing tests) that call these endpoints without a body UUID, they will silently break after merge. This behavior change is not mentioned in the PR description.

**Action:** Please confirm this stricter behavior for admins is intentional. If so, consider documenting it with a test or a code comment explaining why admins must now provide missionUUID.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +213 to +224
export function CanMigrateMissionByBody() {
return applyDecorators(
SetMetadata('CanMigrateMissionByBody', true),
UseGuards(MigrateMissionByBodyGuard),
ApiResponse({
status: 403,
type: ForbiddenException,
description:
'User does not have permissions to migrate the specified mission.',
}),
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CanMigrateMissionByBody missing HTTP 401 documentation

MigrateMissionByBodyGuard.canActivate throws UnauthorizedException (HTTP 401) when an API key is detected:

if (apiKey) {
    throw new UnauthorizedException('CLI Keys cannot move missions');
}

But CanMigrateMissionByBody only documents status 403, not 401. This is inconsistent with the parallel CanMigrateProjectByBody decorator (line 148), which correctly documents 401. Clients or generated SDK consumers relying on the Swagger spec won't know to handle a 401 response.

Suggested change
export function CanMigrateMissionByBody() {
return applyDecorators(
SetMetadata('CanMigrateMissionByBody', true),
UseGuards(MigrateMissionByBodyGuard),
ApiResponse({
status: 403,
type: ForbiddenException,
description:
'User does not have permissions to migrate the specified mission.',
}),
);
}
export function CanMigrateMissionByBody() {
return applyDecorators(
SetMetadata('CanMigrateMissionByBody', true),
UseGuards(MigrateMissionByBodyGuard),
ApiResponse({
status: 401,
type: UnauthorizedExceptionDto,
description:
'User does not have permissions to migrate the specified mission.',
}),
);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/src/endpoints/auth/roles.decorator.ts
Line: 213-224

Comment:
`CanMigrateMissionByBody` missing HTTP 401 documentation

`MigrateMissionByBodyGuard.canActivate` throws `UnauthorizedException` (HTTP 401) when an API key is detected:

```ts
if (apiKey) {
    throw new UnauthorizedException('CLI Keys cannot move missions');
}
```

But `CanMigrateMissionByBody` only documents status 403, not 401. This is inconsistent with the parallel `CanMigrateProjectByBody` decorator (line 148), which correctly documents 401. Clients or generated SDK consumers relying on the Swagger spec won't know to handle a 401 response.

```suggestion
export function CanMigrateMissionByBody() {
    return applyDecorators(
        SetMetadata('CanMigrateMissionByBody', true),
        UseGuards(MigrateMissionByBodyGuard),
        ApiResponse({
            status: 401,
            type: UnauthorizedExceptionDto,
            description:
                'User does not have permissions to migrate the specified mission.',
        }),
    );
}
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 42dc87eb. CanMigrateMissionByBody now documents 401 (API key not allowed) in addition to the existing 403 response.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@Idate96
Copy link
Contributor Author

Idate96 commented Mar 3, 2026

Addressed review feedback in 42dc87e:

  • Added missing 401 Swagger response docs to CanMigrateMissionByBody.
  • Aligned MigrateProjectByBodyGuard validation order with mission migration guard (UUID validation before API-key rejection) for consistency.

I intentionally kept CLI project migration 409 mapped to ProjectValidationError in this PR because backend returns 409 for multiple conflict causes; we can add a dedicated ProjectConflictError in a follow-up if we want finer conflict typing.

Comment on lines +148 to +159
export function CanMigrateProjectByBody() {
return applyDecorators(
SetMetadata('CanMigrateProjectByBody', true),
UseGuards(MigrateProjectByBodyGuard),
ApiResponse({
status: 401,
type: UnauthorizedExceptionDto,
description:
'User does not have permissions to migrate between the specified projects.',
}),
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CanMigrateProjectByBody decorator is missing HTTP 403 documentation.

MigrateProjectByBodyGuard.canActivate can return false when canAccessProject evaluates to false for either the source or target project, which NestJS translates to a 403 Forbidden response. However, the decorator only documents the 401 case:

ApiResponse({ status: 401, ... })
// 403 is never declared

The parallel decorator CanMigrateMissionByBody (lines 213-229) correctly documents both 401 and 403. Clients and generated SDK consumers relying on the Swagger spec won't know to handle a 403 response on POST /projects/migrate.

Suggested change
export function CanMigrateProjectByBody() {
return applyDecorators(
SetMetadata('CanMigrateProjectByBody', true),
UseGuards(MigrateProjectByBodyGuard),
ApiResponse({
status: 401,
type: UnauthorizedExceptionDto,
description:
'User does not have permissions to migrate between the specified projects.',
}),
);
}
export function CanMigrateProjectByBody() {
return applyDecorators(
SetMetadata('CanMigrateProjectByBody', true),
UseGuards(MigrateProjectByBodyGuard),
ApiResponse({
status: 401,
type: UnauthorizedExceptionDto,
description:
'User does not have permissions to migrate between the specified projects.',
}),
ApiResponse({
status: 403,
type: ForbiddenException,
description:
'User does not have sufficient access rights on the source or target project.',
}),
);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/src/endpoints/auth/roles.decorator.ts
Line: 148-159

Comment:
`CanMigrateProjectByBody` decorator is missing HTTP 403 documentation. 

`MigrateProjectByBodyGuard.canActivate` can return `false` when `canAccessProject` evaluates to `false` for either the source or target project, which NestJS translates to a **403 Forbidden** response. However, the decorator only documents the 401 case:

```ts
ApiResponse({ status: 401, ... })
// 403 is never declared
```

The parallel decorator `CanMigrateMissionByBody` (lines 213-229) correctly documents both 401 and 403. Clients and generated SDK consumers relying on the Swagger spec won't know to handle a 403 response on `POST /projects/migrate`.

```suggestion
export function CanMigrateProjectByBody() {
    return applyDecorators(
        SetMetadata('CanMigrateProjectByBody', true),
        UseGuards(MigrateProjectByBodyGuard),
        ApiResponse({
            status: 401,
            type: UnauthorizedExceptionDto,
            description:
                'User does not have permissions to migrate between the specified projects.',
        }),
        ApiResponse({
            status: 403,
            type: ForbiddenException,
            description:
                'User does not have sufficient access rights on the source or target project.',
        }),
    );
}
```

How can I resolve this? If you propose a fix, please make it concise.

@Idate96 Idate96 changed the title Add server-side mission and project migration APIs with CLI support Unify mission move across backend CLI and UI with project migration support Mar 3, 2026
Comment on lines +608 to +613
const targetMissionNames = new Set(
targetMissions.map((m) => m.name),
);
const collidingMissionNames = sourceMissions
.map((mission) => mission.name)
.filter((name) => targetMissionNames.has(name));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The collision check here uses case-sensitive JavaScript Set.has(), but the parallel moveMissions method (same PR) uses case-insensitive DB queries via LOWER(name) = LOWER(:name).

This inconsistency means migrateProject will silently skip collisions between case variants (e.g., SomeMission vs somemission). If the DB has a case-insensitive unique constraint, the collision is caught later as a generic 23505 error without naming the offending missions.

For consistency and clearer error messages, apply the same case-insensitive normalization:

Suggested change
const targetMissionNames = new Set(
targetMissions.map((m) => m.name),
);
const collidingMissionNames = sourceMissions
.map((mission) => mission.name)
.filter((name) => targetMissionNames.has(name));
const targetMissionNamesLower = new Set(
targetMissions.map((m) => m.name.toLowerCase()),
);
const collidingMissionNames = sourceMissions
.map((mission) => mission.name)
.filter((name) =>
targetMissionNamesLower.has(name.toLowerCase()),
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/src/services/project.service.ts
Line: 608-613

Comment:
The collision check here uses case-sensitive JavaScript `Set.has()`, but the parallel `moveMissions` method (same PR) uses case-insensitive DB queries via `LOWER(name) = LOWER(:name)`.

This inconsistency means `migrateProject` will silently skip collisions between case variants (e.g., `SomeMission` vs `somemission`). If the DB has a case-insensitive unique constraint, the collision is caught later as a generic `23505` error without naming the offending missions.

For consistency and clearer error messages, apply the same case-insensitive normalization:

```suggestion
                const targetMissionNamesLower = new Set(
                    targetMissions.map((m) => m.name.toLowerCase()),
                );
                const collidingMissionNames = sourceMissions
                    .map((mission) => mission.name)
                    .filter((name) =>
                        targetMissionNamesLower.has(name.toLowerCase()),
                    );
```

How can I resolve this? If you propose a fix, please make it concise.

- update_file
- update_mission
- update_project
- migrate_mission
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module docstring lists migrate_mission, but the actual function defined below (line 229) is move_missions. This will mislead readers scanning the docstring to discover available functionality.

Suggested change
- migrate_mission
- move_missions
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/kleinkram/core.py
Line: 10

Comment:
The module docstring lists `migrate_mission`, but the actual function defined below (line 229) is `move_missions`. This will mislead readers scanning the docstring to discover available functionality.

```suggestion
- move_missions
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +588 to +593
const archiveNameTaken = await projectRepository.exists({
where: {
uuid: Not(sourceProjectUUID),
name: ILike(archiveSourceProjectAs),
},
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The archive-name uniqueness check at line 591 uses ILike(archiveSourceProjectAs), which treats SQL wildcards _ (matches any single character) and % (matches any sequence) as special characters. A user archiving to old_backup would receive a spurious 409 conflict if a project named old backup, oldXbackup, etc. exists, even if no project named old_backup exists.

The parallel mission collision detection in mission.service.ts handles this correctly using LOWER(mission.name) = LOWER(:name), which is exact case-insensitive matching without wildcard interpretation. For consistency and correctness, apply the same pattern here:

Suggested change
const archiveNameTaken = await projectRepository.exists({
where: {
uuid: Not(sourceProjectUUID),
name: ILike(archiveSourceProjectAs),
},
});
const archiveNameTaken = await projectRepository
.createQueryBuilder('project')
.where('project.uuid != :sourceProjectUUID', { sourceProjectUUID })
.andWhere('LOWER(project.name) = LOWER(:name)', {
name: archiveSourceProjectAs,
})
.getExists();
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/src/services/project.service.ts
Line: 588-593

Comment:
The archive-name uniqueness check at line 591 uses `ILike(archiveSourceProjectAs)`, which treats SQL wildcards `_` (matches any single character) and `%` (matches any sequence) as special characters. A user archiving to `old_backup` would receive a spurious 409 conflict if a project named `old backup`, `oldXbackup`, etc. exists, even if no project named `old_backup` exists.

The parallel mission collision detection in `mission.service.ts` handles this correctly using `LOWER(mission.name) = LOWER(:name)`, which is exact case-insensitive matching without wildcard interpretation. For consistency and correctness, apply the same pattern here:

```suggestion
const archiveNameTaken = await projectRepository
    .createQueryBuilder('project')
    .where('project.uuid != :sourceProjectUUID', { sourceProjectUUID })
    .andWhere('LOWER(project.name) = LOWER(:name)', {
        name: archiveSourceProjectAs,
    })
    .getExists();
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +532 to +543
targetMissionName,
] of targetMissionNames) {
const exists = await missionRepository.exists({
where: {
name: targetMissionName,
uuid: Not(missionUUID),
project: { uuid: targetProjectUUID },
},
});
if (exists) {
throw new ConflictException(
`Mission name '${targetMissionName}' already exists in the target project`,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collision check is case-sensitive, not case-insensitive

The PR description claims the collision check was "fixed from wildcard-prone ILike to exact case-insensitive LOWER(name)=LOWER(:name)", but the implementation uses plain TypeORM equality:

const exists = await missionRepository.exists({
    where: {
        name: targetMissionName,  // ← case-sensitive in PostgreSQL
        uuid: Not(missionUUID),
        project: { uuid: targetProjectUUID },
    },
});

TypeORM translates this to WHERE name = $1, which is case-sensitive by default in PostgreSQL. If the target project has a mission named "MyMission", moving a mission named "mymission" would pass this check. The final outcome depends on whether the DB has a LOWER(name) unique constraint — if it doesn't, duplicates silently coexist; if it does, a generic 23505 error is caught without identifying the offending name.

This contradicts the self-collision check above (lines 514–523), which correctly normalizes to lowercase. Apply the same case-insensitive normalization here:

const exists = await missionRepository
    .createQueryBuilder('mission')
    .where('LOWER(mission.name) = LOWER(:name)', { name: targetMissionName })
    .andWhere('mission.uuid != :uuid', { uuid: missionUUID })
    .andWhere('mission.projectUuid = :projectUUID', { projectUUID: targetProjectUUID })
    .getExists();
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/src/services/mission.service.ts
Line: 532-543

Comment:
**Collision check is case-sensitive, not case-insensitive**

The PR description claims the collision check was "fixed from wildcard-prone `ILike` to exact case-insensitive `LOWER(name)=LOWER(:name)`", but the implementation uses plain TypeORM equality:

```ts
const exists = await missionRepository.exists({
    where: {
        name: targetMissionName,  // ← case-sensitive in PostgreSQL
        uuid: Not(missionUUID),
        project: { uuid: targetProjectUUID },
    },
});
```

TypeORM translates this to `WHERE name = $1`, which is **case-sensitive** by default in PostgreSQL. If the target project has a mission named `"MyMission"`, moving a mission named `"mymission"` would pass this check. The final outcome depends on whether the DB has a `LOWER(name)` unique constraint — if it doesn't, duplicates silently coexist; if it does, a generic `23505` error is caught without identifying the offending name.

This contradicts the self-collision check above (lines 514–523), which correctly normalizes to lowercase. Apply the same case-insensitive normalization here:

```ts
const exists = await missionRepository
    .createQueryBuilder('mission')
    .where('LOWER(mission.name) = LOWER(:name)', { name: targetMissionName })
    .andWhere('mission.uuid != :uuid', { uuid: missionUUID })
    .andWhere('mission.projectUuid = :projectUUID', { projectUUID: targetProjectUUID })
    .getExists();
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +176 to +185
const result = await this.missionService.moveMissions(
dto.missionUUIDs,
dto.targetProjectUUID,
dto.newName,
);
return {
success: true,
movedMissionCount: result.movedMissionCount,
targetProjectUUID: dto.targetProjectUUID,
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

movedMissionUUIDs silently dropped from response

The service returns both movedMissionCount and movedMissionUUIDs, but the controller discards the UUIDs:

return {
    success: true,
    movedMissionCount: result.movedMissionCount,
    targetProjectUUID: dto.targetProjectUUID,
    // movedMissionUUIDs: result.movedMissionUUIDs  ← never forwarded
};

MoveMissionsResponseDto doesn't declare the field either, so callers (CLI, frontend) cannot programmatically know which UUIDs were actually moved in a bulk operation. The parallel MigrateProjectResponseDto does expose movedMissionUUIDs, making this an API inconsistency.

Add the field to both the DTO and the controller response:

// move-missions.dto.ts
export class MoveMissionsResponseDto {
    @ApiProperty()
    @IsBoolean()
    success!: boolean;

    @ApiProperty()
    movedMissionCount!: number;

    @ApiProperty({ type: [String] })
    @IsArray()
    @IsUUID('4', { each: true })
    movedMissionUUIDs!: string[];

    @ApiProperty()
    targetProjectUUID!: string;
}
// controller
return {
    success: true,
    movedMissionCount: result.movedMissionCount,
    movedMissionUUIDs: result.movedMissionUUIDs,
    targetProjectUUID: dto.targetProjectUUID,
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/src/endpoints/mission/mission.controller.ts
Line: 176-185

Comment:
**`movedMissionUUIDs` silently dropped from response**

The service returns both `movedMissionCount` and `movedMissionUUIDs`, but the controller discards the UUIDs:

```ts
return {
    success: true,
    movedMissionCount: result.movedMissionCount,
    targetProjectUUID: dto.targetProjectUUID,
    // movedMissionUUIDs: result.movedMissionUUIDs  ← never forwarded
};
```

`MoveMissionsResponseDto` doesn't declare the field either, so callers (CLI, frontend) cannot programmatically know which UUIDs were actually moved in a bulk operation. The parallel `MigrateProjectResponseDto` **does** expose `movedMissionUUIDs`, making this an API inconsistency.

Add the field to both the DTO and the controller response:

```ts
// move-missions.dto.ts
export class MoveMissionsResponseDto {
    @ApiProperty()
    @IsBoolean()
    success!: boolean;

    @ApiProperty()
    movedMissionCount!: number;

    @ApiProperty({ type: [String] })
    @IsArray()
    @IsUUID('4', { each: true })
    movedMissionUUIDs!: string[];

    @ApiProperty()
    targetProjectUUID!: string;
}
```

```ts
// controller
return {
    success: true,
    movedMissionCount: result.movedMissionCount,
    movedMissionUUIDs: result.movedMissionUUIDs,
    targetProjectUUID: dto.targetProjectUUID,
};
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +179 to +180
mission_ids, mission_patterns = split_args(mission)
if mission_patterns and not source_project:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The aggregate split_args(mission) call assigns both mission_ids and mission_patterns, but mission_ids is never used—only mission_patterns is checked in the conditional. The actual per-mission resolution in the loop (line 188) calls split_args([mission_selector]) fresh. Remove the unused assignment:

Suggested change
mission_ids, mission_patterns = split_args(mission)
if mission_patterns and not source_project:
_, mission_patterns = split_args(mission)
if mission_patterns and not source_project:
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/kleinkram/cli/_mission.py
Line: 179-180

Comment:
The aggregate `split_args(mission)` call assigns both `mission_ids` and `mission_patterns`, but `mission_ids` is never used—only `mission_patterns` is checked in the conditional. The actual per-mission resolution in the loop (line 188) calls `split_args([mission_selector])` fresh. Remove the unused assignment:

```suggestion
    _, mission_patterns = split_args(mission)
    if mission_patterns and not source_project:
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +545 to +549
if resp.status_code == 409:
raise MissionExists(
"target project already has a mission with one of the requested names: "
+ ", ".join(str(mission_id) for mission_id in mission_ids)
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 409 error message claims to show "requested names" but joins mission_ids (UUIDs), producing opaque errors. Users can't identify which name actually conflicts.

Since the backend error message should include the colliding name, consider forwarding that directly:

Suggested change
if resp.status_code == 409:
raise MissionExists(
"target project already has a mission with one of the requested names: "
+ ", ".join(str(mission_id) for mission_id in mission_ids)
)
if resp.status_code == 409:
raise MissionExists(_extract_error_message(resp))

This leverages the backend's error detail instead of showing mission UUIDs to the user.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/kleinkram/api/routes.py
Line: 545-549

Comment:
The 409 error message claims to show "requested names" but joins `mission_ids` (UUIDs), producing opaque errors. Users can't identify which name actually conflicts.

Since the backend error message should include the colliding name, consider forwarding that directly:

```suggestion
    if resp.status_code == 409:
        raise MissionExists(_extract_error_message(resp))
```

This leverages the backend's error detail instead of showing mission UUIDs to the user.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps
Copy link

greptile-apps bot commented Mar 3, 2026

Additional Comments (1)

packages/backend-common/src/modules/action-dispatcher/action-dispatcher.service.ts
Loki unreachability is now downgraded from a fatal ConflictException to a warning log, allowing action dispatch to proceed without an audit trail. This is a significant behavioral change: actions will execute even when the logging backend is unavailable.

If this change is intentional (i.e., Loki is now optional), add a comment explaining the rationale. If callers in other services relied on the 409 response to detect Loki outages, they should be updated accordingly:

        } catch {
            // Loki is optional—log the issue but allow dispatch to continue.
            // Actions will execute without audit trail until logging is restored.
            this.logger.warn(
                'Loki logging system is down or unreachable; continuing action dispatch without audit',
            );
        }
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/backend-common/src/modules/action-dispatcher/action-dispatcher.service.ts
Line: 110-118

Comment:
Loki unreachability is now downgraded from a fatal `ConflictException` to a warning log, allowing action dispatch to proceed without an audit trail. This is a significant behavioral change: actions will execute even when the logging backend is unavailable.

If this change is intentional (i.e., Loki is now optional), add a comment explaining the rationale. If callers in other services relied on the 409 response to detect Loki outages, they should be updated accordingly:

```suggestion
        } catch {
            // Loki is optional—log the issue but allow dispatch to continue.
            // Actions will execute without audit trail until logging is restored.
            this.logger.warn(
                'Loki logging system is down or unreachable; continuing action dispatch without audit',
            );
        }
```

How can I resolve this? If you propose a fix, please make it concise.

@Idate96
Copy link
Contributor Author

Idate96 commented Mar 3, 2026

Final CI fix note (head 7aa5b2ba):

test-api mission-move failures were caused by response-output validation, not move permission logic.

  • Error surfaced as 500: Validation failed: 2 errors. Check backend logs for details.
  • Root cause: MoveMissionsResponseDto only validated success; with output whitelisting enabled, movedMissionCount and targetProjectUUID were treated as non-whitelisted fields.

Fix applied:

  • MoveMissionsResponseDto.movedMissionCount -> @IsInt()
  • MoveMissionsResponseDto.targetProjectUUID -> @IsUUID('4')

After this, all PR checks are green (eslint, cli-tests, test-api, Greptile Review).

Comment on lines +115 to +116
this.logger.error('Loki logging system is down or unreachable');
throw new ConflictException(
'Logging system (Loki) is not available. Please try again later.',
this.logger.warn(
'Loki logging system is down or unreachable; continuing action dispatch',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this change is totally unrelated to this PR

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. I dropped the unrelated change from this PR in bb2c3f1.

Copy link
Member

@wp99cp wp99cp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please remove the unrelated changes

@@ -164,12 +166,23 @@ export class MissionController {

@Post('move')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Idate96, can you explain the difference between this mission/move endpoint and the new project/migrate endpoint? For me, these seem to be identical in behaviour. Or did I miss something?

Copy link
Contributor Author

@Idate96 Idate96 Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intent is:

  • /mission/move: move explicitly selected mission(s) (missionUUIDs) to a target project; optional newName is only valid for a single mission.
  • /projects/migrate: move all missions from a source project to a target project, with optional archive rename of the source project.

Comment on lines +80 to +88
const isNoopMove = computed<boolean>(() => {
if (!selectedProjectUuid.value || selectedMissions.value.length === 0) {
return false;
}

return selectedMissions.value.every(
(mission) => mission.project.uuid === selectedProjectUuid.value,
);
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isNoopMove disables only for pure-noop batch, but backend rejects if ANY mission is already in the target

isNoopMove uses every() — so the OK button stays enabled as long as at least one selected mission is not already in the target project. However, the backend (moveMissions service, line 494) throws a ConflictException for each individual mission whose project.uuid === targetProject.uuid. This means a user moving a mixed batch (some missions already in the target, some not) will see the button enabled, click it, and receive an opaque 409 error.

For consistency with the backend's all-or-nothing semantics, the check should use some() instead:

Suggested change
const isNoopMove = computed<boolean>(() => {
if (!selectedProjectUuid.value || selectedMissions.value.length === 0) {
return false;
}
return selectedMissions.value.every(
(mission) => mission.project.uuid === selectedProjectUuid.value,
);
});
const isNoopMove = computed<boolean>(() => {
if (!selectedProjectUuid.value || selectedMissions.value.length === 0) {
return false;
}
return selectedMissions.value.some(
(mission) => mission.project.uuid === selectedProjectUuid.value,
);
});

This way the button is disabled (and a tooltip can explain why) whenever any selected mission would conflict, matching what the backend actually enforces.

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/dialogs/modify-mission-location-dialog.vue
Line: 80-88

Comment:
**`isNoopMove` disables only for pure-noop batch, but backend rejects if ANY mission is already in the target**

`isNoopMove` uses `every()` — so the OK button stays **enabled** as long as at least one selected mission is *not* already in the target project. However, the backend (`moveMissions` service, line 494) throws a `ConflictException` for each individual mission whose `project.uuid === targetProject.uuid`. This means a user moving a mixed batch (some missions already in the target, some not) will see the button enabled, click it, and receive an opaque 409 error.

For consistency with the backend's all-or-nothing semantics, the check should use `some()` instead:

```suggestion
const isNoopMove = computed<boolean>(() => {
    if (!selectedProjectUuid.value || selectedMissions.value.length === 0) {
        return false;
    }

    return selectedMissions.value.some(
        (mission) => mission.project.uuid === selectedProjectUuid.value,
    );
});
```

This way the button is disabled (and a tooltip can explain why) whenever any selected mission would conflict, matching what the backend actually enforces.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +343 to +353
for (const missionUUID of missionUUIDs) {
const canDeleteSourceMission =
await this.missionGuardService.canAccessMission(
user,
missionUUID,
AccessGroupRights.DELETE,
);
if (!canDeleteSourceMission) {
return false;
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sequential permission checks — N round-trips per mission

The guard awaits each canAccessMission call one-by-one in a for loop. For a bulk move of N missions this fires N sequential database queries before the request even reaches the service layer. While the impact is negligible for typical batch sizes (1–5 missions), it becomes a noticeable bottleneck as missionUUIDs.length grows.

Consider running the checks in parallel using Promise.all:

Suggested change
for (const missionUUID of missionUUIDs) {
const canDeleteSourceMission =
await this.missionGuardService.canAccessMission(
user,
missionUUID,
AccessGroupRights.DELETE,
);
if (!canDeleteSourceMission) {
return false;
}
}
const results = await Promise.all(
missionUUIDs.map((missionUUID) =>
this.missionGuardService.canAccessMission(
user,
missionUUID,
AccessGroupRights.DELETE,
),
),
);
if (results.some((ok) => !ok)) {
return false;
}

This keeps the same all-or-nothing semantics while reducing latency from O(N) sequential round-trips to a single parallel fan-out.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/src/endpoints/auth/guards/mission.guards.ts
Line: 343-353

Comment:
**Sequential permission checks — N round-trips per mission**

The guard awaits each `canAccessMission` call one-by-one in a `for` loop. For a bulk move of N missions this fires N sequential database queries before the request even reaches the service layer. While the impact is negligible for typical batch sizes (1–5 missions), it becomes a noticeable bottleneck as `missionUUIDs.length` grows.

Consider running the checks in parallel using `Promise.all`:

```suggestion
const results = await Promise.all(
    missionUUIDs.map((missionUUID) =>
        this.missionGuardService.canAccessMission(
            user,
            missionUUID,
            AccessGroupRights.DELETE,
        ),
    ),
);
if (results.some((ok) => !ok)) {
    return false;
}
```

This keeps the same all-or-nothing semantics while reducing latency from O(N) sequential round-trips to a single parallel fan-out.

How can I resolve this? If you propose a fix, please make it concise.

@Idate96
Copy link
Contributor Author

Idate96 commented Mar 17, 2026

Addressed the move/migrate collision issues in 3529d9e2.

I reproduced three live regressions against the local PR stack before the fix:

  • POST /mission/move allowed moving Alpha into a target project that already contained alpha
  • POST /projects/migrate allowed migrating Beta into a target project that already contained beta
  • archiveSourceProjectAs used wildcard-prone matching, so a rename like ...a_ falsely conflicted with an existing project ...ab

Changes in this commit:

  • mission move now checks target mission-name collisions case-insensitively
  • project migration now uses exact case-insensitive archive-name matching and rejects case-only duplicates in source/target mission sets
  • CLI move/migrate routes now forward backend 409 detail instead of opaque UUID-only conflict text
  • added focused regression coverage for the updated service/CLI behavior

Verification on the running local stack after the patch:

  • move Alpha -> alpha now returns 409
  • migrate Beta -> beta now returns 409
  • archive rename ...a_ with existing project ...ab now succeeds with 201

I could not use the chrome-cdp skill here because the installed runtime wrapper scripts/cdp is missing, so I used the JS/HTTP fallback against the live local app instead.

Comment on lines +555 to +575
}
}
await this.missionRepository.update(uuid, {
name: name,
});
return this.missionRepository.findOne({ where: { uuid } });

for (const file of rollbackTags) {
await this.dataStorage.addTags(file.fileUUID, {
filename: file.filename,
missionUuid: file.missionUUID,
projectUuid: targetProjectUUID,
});
}
});
} catch (error) {
if (
error instanceof QueryFailedError &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
error.driverError?.code === "23505"
) {
throw new ConflictException(
"Mission with that name already exists in the target project",
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tag-update success window before DB commit creates orphaned storage state

Inside the dataSource.transaction callback, this.dataStorage.addTags(...) is called for every file (lines after the missionRepository.update loop). These storage calls are external HTTP/SDK calls that are not enrolled in the DB transaction. This means:

  1. The DB updates run → tags are then applied to storage (inside the callback, before TypeORM commits).
  2. If a storage call succeeds for file N but then the dataSource.transaction machinery fails to commit (e.g., transient DB error at commit time), the DB is rolled back, but the storage tags for file N already point to the target project — the source project data in the DB, the target project tag in storage.

The current catch block only runs when the callback itself rejects, not when the post-callback commit fails. If the commit-level error propagates through the try, the rollback loop would still run (good), but rollbackTags would already be fully populated, so the restoration would execute correctly.

This is a known limitation of non-atomic distributed operations and the best-effort rollback mitigates it. However, the code comment that says "best-effort rollback" only appears in the catch, not at the storage-call site, which may surprise a future maintainer. Consider adding a short inline comment before the addTags loop clarifying that these calls are intentionally made inside the callback so that a storage failure rolls back the DB.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/src/services/mission.service.ts
Line: 555-575

Comment:
**Tag-update success window before DB commit creates orphaned storage state**

Inside the `dataSource.transaction` callback, `this.dataStorage.addTags(...)` is called for every file (lines after the `missionRepository.update` loop). These storage calls are external HTTP/SDK calls that are not enrolled in the DB transaction. This means:

1. The DB updates run → tags are then applied to storage (inside the callback, before TypeORM commits).
2. If a storage call succeeds for file N but then the `dataSource.transaction` machinery fails to commit (e.g., transient DB error at commit time), the DB is rolled back, but the storage tags for file N already point to the target project — the source project data in the DB, the target project tag in storage.

The current `catch` block only runs when the callback itself rejects, not when the post-callback commit fails. If the commit-level error propagates through the `try`, the rollback loop would still run (good), but `rollbackTags` would already be fully populated, so the restoration would execute correctly.

This is a known limitation of non-atomic distributed operations and the best-effort rollback mitigates it. However, the code comment that says "best-effort rollback" only appears in the catch, not at the storage-call site, which may surprise a future maintainer. Consider adding a short inline comment before the `addTags` loop clarifying that these calls are intentionally made inside the callback so that a storage failure rolls back the DB.

How can I resolve this? If you propose a fix, please make it concise.

@Idate96
Copy link
Contributor Author

Idate96 commented Mar 22, 2026

Updated the branch in fd2a8436.

What changed:

  • merged current origin/dev and resolved the remaining conflicts in project.module.ts and project.service.ts
  • aligned the access-group handling there with current dev (userUuid) while keeping the project-migrate/move logic from this PR
  • addressed the still-valid review items:
    • CanMigrateProjectByBody now documents 400 + 403
    • CanMoveMission now documents 400
    • MoveMissionsResponseDto / mission controller now return movedMissionUUIDs
    • bulk mission delete-access checks now run via Promise.all(...)
    • move dialog disables mixed no-op batches (some(...) instead of every(...))
    • removed the unused aggregate mission_ids assignment in CLI move
    • added an inline comment clarifying why storage tag updates stay inside the transaction callback
  • added an auth test assertion for the move response payload

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants